@cosmoops/connect-sdk 0.17.0 → 0.19.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 +124 -0
- package/README.md +20 -1
- package/dist/cjs/client/components/booking-calendar.js +170 -0
- package/dist/cjs/client/components/booking-dialogs.js +124 -0
- package/dist/cjs/client/components/booking-grid.js +206 -0
- package/dist/cjs/client/index.js +15 -1
- package/dist/cjs/client/use-scheduling.js +113 -0
- package/dist/cjs/server/client.js +5 -1
- package/dist/cjs/server/handler.js +136 -0
- package/dist/cjs/server/resources/contacts.js +66 -0
- package/dist/cjs/server/resources/scheduling.js +32 -1
- package/dist/esm/client/components/booking-calendar.js +167 -0
- package/dist/esm/client/components/booking-dialogs.js +120 -0
- package/dist/esm/client/components/booking-grid.js +199 -0
- package/dist/esm/client/index.js +4 -0
- package/dist/esm/client/use-scheduling.js +107 -0
- package/dist/esm/server/client.js +5 -1
- package/dist/esm/server/handler.js +136 -0
- package/dist/esm/server/resources/contacts.js +62 -0
- package/dist/esm/server/resources/scheduling.js +32 -1
- package/dist/types/client/components/booking-calendar.d.ts +38 -0
- package/dist/types/client/components/booking-dialogs.d.ts +33 -0
- package/dist/types/client/components/booking-grid.d.ts +102 -0
- package/dist/types/client/index.d.ts +5 -0
- package/dist/types/client/use-scheduling.d.ts +93 -0
- package/dist/types/server/client.d.ts +4 -1
- package/dist/types/server/index.d.ts +2 -1
- package/dist/types/server/resources/contacts.d.ts +60 -0
- package/dist/types/server/resources/scheduling.d.ts +51 -6
- package/dist/types/types.d.ts +58 -0
- package/package.json +1 -1
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.zonedInstant = zonedInstant;
|
|
4
|
+
exports.dayColumns = dayColumns;
|
|
5
|
+
exports.dayStartInstant = dayStartInstant;
|
|
6
|
+
exports.layoutBookings = layoutBookings;
|
|
7
|
+
exports.hourMarks = hourMarks;
|
|
8
|
+
/**
|
|
9
|
+
* An instant, as the local day and minute it lands on in `timeZone`.
|
|
10
|
+
*
|
|
11
|
+
* Built from `formatToParts` rather than by adding a fixed offset. An offset
|
|
12
|
+
* is only correct until a DST boundary, and "add 5.5 hours" is wrong the
|
|
13
|
+
* moment the same code is pointed at a zone that observes one - which is
|
|
14
|
+
* exactly the kind of bug that appears twice a year and never in a test.
|
|
15
|
+
*/
|
|
16
|
+
function zonedInstant(instant, timeZone) {
|
|
17
|
+
const parts = partsFormatter(timeZone).formatToParts(instant);
|
|
18
|
+
const read = (type) => parts.find((part) => part.type === type)?.value ?? "00";
|
|
19
|
+
// `hourCycle: "h23"` so midnight is 00 rather than 24 - `hour12: false`
|
|
20
|
+
// alone still yields 24 in some engines, and a 24 would push midnight onto
|
|
21
|
+
// the wrong day.
|
|
22
|
+
return {
|
|
23
|
+
dateKey: `${read("year")}-${read("month")}-${read("day")}`,
|
|
24
|
+
minutes: Number(read("hour")) * 60 + Number(read("minute")),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const formatterCache = new Map();
|
|
28
|
+
function partsFormatter(timeZone) {
|
|
29
|
+
let formatter = formatterCache.get(timeZone);
|
|
30
|
+
if (!formatter) {
|
|
31
|
+
// Cached because a grid calls this once per booking per render, and
|
|
32
|
+
// constructing an `Intl.DateTimeFormat` is the expensive part by a wide
|
|
33
|
+
// margin.
|
|
34
|
+
formatter = new Intl.DateTimeFormat("en-CA", {
|
|
35
|
+
timeZone,
|
|
36
|
+
year: "numeric",
|
|
37
|
+
month: "2-digit",
|
|
38
|
+
day: "2-digit",
|
|
39
|
+
hour: "2-digit",
|
|
40
|
+
minute: "2-digit",
|
|
41
|
+
hourCycle: "h23",
|
|
42
|
+
});
|
|
43
|
+
formatterCache.set(timeZone, formatter);
|
|
44
|
+
}
|
|
45
|
+
return formatter;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* `count` consecutive days starting at the local day `anchor` falls on.
|
|
49
|
+
*
|
|
50
|
+
* Days are stepped by adding 24 hours and re-reading the zone rather than by
|
|
51
|
+
* incrementing a date field, so a day that is 23 or 25 hours long still
|
|
52
|
+
* produces the right sequence of `dateKey`s - the midday anchor is what keeps
|
|
53
|
+
* a DST shift from landing the step on the previous or next day.
|
|
54
|
+
*/
|
|
55
|
+
function dayColumns(anchor, timeZone, count, now) {
|
|
56
|
+
const todayKey = zonedInstant(now, timeZone).dateKey;
|
|
57
|
+
const columns = [];
|
|
58
|
+
for (let index = 0; index < count; index += 1) {
|
|
59
|
+
const probe = new Date(anchor.getTime() + index * 24 * 60 * 60_000);
|
|
60
|
+
const { dateKey } = zonedInstant(probe, timeZone);
|
|
61
|
+
const startsAt = dayStartInstant(dateKey, timeZone);
|
|
62
|
+
columns.push({
|
|
63
|
+
dateKey,
|
|
64
|
+
label: new Intl.DateTimeFormat("en-IN", {
|
|
65
|
+
timeZone,
|
|
66
|
+
weekday: "short",
|
|
67
|
+
day: "numeric",
|
|
68
|
+
}).format(startsAt),
|
|
69
|
+
monthLabel: new Intl.DateTimeFormat("en-IN", { timeZone, month: "short" }).format(startsAt),
|
|
70
|
+
startsAt,
|
|
71
|
+
isToday: dateKey === todayKey,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return columns;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Local midnight of `dateKey` in `timeZone`, as an instant.
|
|
78
|
+
*
|
|
79
|
+
* Solved by probing rather than by arithmetic: guess UTC midnight, read what
|
|
80
|
+
* local time that actually is, and subtract the difference. One correction is
|
|
81
|
+
* always enough because a zone's offset cannot change within the few hours the
|
|
82
|
+
* correction spans - and unlike a fixed-offset formula it is right across DST
|
|
83
|
+
* and for the half-hour zones (`Asia/Kolkata`, `Australia/Adelaide`) that a
|
|
84
|
+
* naive implementation quietly breaks on.
|
|
85
|
+
*/
|
|
86
|
+
function dayStartInstant(dateKey, timeZone) {
|
|
87
|
+
const utcGuess = new Date(`${dateKey}T00:00:00.000Z`);
|
|
88
|
+
const { dateKey: landedOn, minutes } = zonedInstant(utcGuess, timeZone);
|
|
89
|
+
// Whether the guess landed on the day before, the day itself, or the day
|
|
90
|
+
// after decides which way to correct.
|
|
91
|
+
const dayDelta = compareDateKeys(landedOn, dateKey);
|
|
92
|
+
return new Date(utcGuess.getTime() - (minutes + dayDelta * 1440) * 60_000);
|
|
93
|
+
}
|
|
94
|
+
function compareDateKeys(a, b) {
|
|
95
|
+
if (a === b)
|
|
96
|
+
return 0;
|
|
97
|
+
return a < b ? -1 : 1;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* A fifteen-minute appointment on a twelve-hour grid is two percent tall -
|
|
101
|
+
* unreadable and unclickable. Short appointments are drawn at a floor and
|
|
102
|
+
* allowed to overhang, which is what every calendar does and what makes a
|
|
103
|
+
* back-to-back pair still look like two things.
|
|
104
|
+
*/
|
|
105
|
+
const MIN_VISIBLE_FRACTION = 0.02;
|
|
106
|
+
/**
|
|
107
|
+
* Places bookings on the grid, side by side where they collide.
|
|
108
|
+
*
|
|
109
|
+
* Overlap handling is the part worth stating. Two appointments at the same
|
|
110
|
+
* time are not an error - a workspace with two hosts has them constantly - and
|
|
111
|
+
* stacking them would hide one entirely. So overlapping bookings are grouped
|
|
112
|
+
* into *clusters* and each cluster is split into equal columns. The cluster is
|
|
113
|
+
* built transitively: A overlapping B and B overlapping C puts all three in one
|
|
114
|
+
* cluster even when A and C do not touch, because anything else lets two of
|
|
115
|
+
* them land on the same column.
|
|
116
|
+
*/
|
|
117
|
+
function layoutBookings(bookings, timeZone, window) {
|
|
118
|
+
const span = window.endMinute - window.startMinute;
|
|
119
|
+
if (span <= 0)
|
|
120
|
+
return [];
|
|
121
|
+
const byDay = new Map();
|
|
122
|
+
for (const booking of bookings) {
|
|
123
|
+
const start = zonedInstant(new Date(booking.startAt), timeZone);
|
|
124
|
+
const end = zonedInstant(new Date(booking.endAt), timeZone);
|
|
125
|
+
// An appointment that crosses local midnight is clamped to the end of the
|
|
126
|
+
// day it started on. Splitting it across two columns would be more correct
|
|
127
|
+
// and is not worth the complexity for a booking grid, where the longest
|
|
128
|
+
// event type is capped at eight hours server-side.
|
|
129
|
+
const endMinutes = end.dateKey === start.dateKey ? end.minutes : 1440;
|
|
130
|
+
const entries = byDay.get(start.dateKey) ?? [];
|
|
131
|
+
entries.push({ booking, start: start.minutes, end: Math.max(endMinutes, start.minutes + 1) });
|
|
132
|
+
byDay.set(start.dateKey, entries);
|
|
133
|
+
}
|
|
134
|
+
const positioned = [];
|
|
135
|
+
for (const [dateKey, entries] of byDay) {
|
|
136
|
+
entries.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
137
|
+
for (const cluster of clusterOverlapping(entries)) {
|
|
138
|
+
cluster.forEach((entry, column) => {
|
|
139
|
+
const clampedStart = Math.max(entry.start, window.startMinute);
|
|
140
|
+
const clampedEnd = Math.min(entry.end, window.endMinute);
|
|
141
|
+
positioned.push({
|
|
142
|
+
booking: entry.booking,
|
|
143
|
+
dateKey,
|
|
144
|
+
top: (clampedStart - window.startMinute) / span,
|
|
145
|
+
height: Math.max((clampedEnd - clampedStart) / span, MIN_VISIBLE_FRACTION),
|
|
146
|
+
column,
|
|
147
|
+
columns: cluster.length,
|
|
148
|
+
clipped: entry.start < window.startMinute || entry.end > window.endMinute,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return positioned;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Transitive overlap grouping - see `layoutBookings`.
|
|
157
|
+
*
|
|
158
|
+
* Entries arrive sorted by start. A cluster stays open while the next entry
|
|
159
|
+
* begins before the *furthest* end seen so far, not before the previous
|
|
160
|
+
* entry's end: a long appointment that swallows several short ones has to keep
|
|
161
|
+
* the whole run together.
|
|
162
|
+
*/
|
|
163
|
+
function clusterOverlapping(entries) {
|
|
164
|
+
const clusters = [];
|
|
165
|
+
let current = [];
|
|
166
|
+
let clusterEnd = -Infinity;
|
|
167
|
+
for (const entry of entries) {
|
|
168
|
+
if (current.length > 0 && entry.start >= clusterEnd) {
|
|
169
|
+
clusters.push(current);
|
|
170
|
+
current = [];
|
|
171
|
+
clusterEnd = -Infinity;
|
|
172
|
+
}
|
|
173
|
+
current.push(entry);
|
|
174
|
+
clusterEnd = Math.max(clusterEnd, entry.end);
|
|
175
|
+
}
|
|
176
|
+
if (current.length > 0)
|
|
177
|
+
clusters.push(current);
|
|
178
|
+
return clusters;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The hour labels down the left edge, inclusive of both ends.
|
|
182
|
+
*
|
|
183
|
+
* Takes no timezone, deliberately: these label *wall-clock hours on the grid*,
|
|
184
|
+
* not instants. The grid is already drawn in the business's zone, so "10 am"
|
|
185
|
+
* means the business's ten - running the label through a zone conversion would
|
|
186
|
+
* shift it away from the row it sits on.
|
|
187
|
+
*/
|
|
188
|
+
function hourMarks(window) {
|
|
189
|
+
const marks = [];
|
|
190
|
+
const formatter = new Intl.DateTimeFormat("en-IN", {
|
|
191
|
+
timeZone: "UTC",
|
|
192
|
+
hour: "numeric",
|
|
193
|
+
hour12: true,
|
|
194
|
+
});
|
|
195
|
+
const firstHour = Math.ceil(window.startMinute / 60);
|
|
196
|
+
const lastHour = Math.floor(window.endMinute / 60);
|
|
197
|
+
for (let hour = firstHour; hour <= lastHour; hour += 1) {
|
|
198
|
+
marks.push({
|
|
199
|
+
minute: hour * 60,
|
|
200
|
+
// A synthetic UTC instant formatted in UTC - the round trip exists only
|
|
201
|
+
// to borrow `Intl`'s "10 am" / "12 pm" spelling, not to convert anything.
|
|
202
|
+
label: formatter.format(new Date(Date.UTC(2000, 0, 1, hour % 24))),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return marks;
|
|
206
|
+
}
|
package/dist/cjs/client/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
4
|
exports.normalizePath = exports.flattenNav = exports.normalizeNav = exports.useActivePath = exports.ConnectSideNav = exports.ConnectSidebarNav = exports.RAIL_EXPANDED = exports.RAIL_COLLAPSED = exports.sidebarRowClass = exports.useConnectShell = exports.ConnectShellProvider = exports.ConnectSidebarRow = exports.ConnectSidebarFooter = exports.ConnectSidebarBody = exports.ConnectSidebarHeader = exports.ConnectSidebar = exports.AppShell = exports.ConnectAccountSessions = exports.ConnectSignOutButton = exports.ConnectHomeButton = exports.ConnectProfileButton = exports.ConnectInvoicesButton = exports.ConnectManageSubscriptionButton = exports.ConnectUserMenu = exports.ConnectProfileModal = exports.ConnectUserButton = exports.ConnectRegisterButton = exports.ConnectLoginButton = exports.ConnectLogo = exports.ConnectMark = exports.ConnectGrievanceNotice = exports.isConnectError = exports.ConnectError = exports.normalizePlanFeatures = exports.blockingBlockers = exports.CONNECT_APP_URL = exports.useConnectResource = exports.useConnectIntegrations = exports.useConnectSessions = exports.useConnectAddonUsage = exports.useConnectCompanyProfile = exports.useConnectApps = exports.useConnectPlans = exports.useConnectEntitlements = exports.useConnectPlatformAccess = exports.useConnectAudience = exports.useConnectWorkspaceType = exports.useConnectUser = exports.useConnect = exports.ConnectProvider = void 0;
|
|
5
5
|
exports.annualSaving = exports.resolveAction = exports.monthlyOf = exports.formatPrice = exports.buildCheckoutUrl = exports.useConnectCheckout = exports.ConnectFilterTabs = exports.ConnectCursorPagination = exports.ConnectConfirmDialog = exports.ConnectLinkButton = exports.ConnectPlansPage = exports.ConnectUsageMeter = exports.ConnectPlatformGate = exports.ConnectWorkspaceTypeGate = exports.useConnectFeature = exports.ConnectFeatureGate = exports.useConnectAccessGate = exports.ConnectAccessGate = exports.ConnectReadinessGate = exports.ConnectReadinessBanner = exports.resolveSubscriptionState = exports.ConnectSeatUsage = exports.ConnectTrialBanner = exports.ConnectSubscriptionCard = exports.ConnectNoSubscriptionCard = exports.ConnectSubscriptionGate = exports.ConnectSubscribeDialog = exports.ConnectPlans = exports.ConnectIntegrationsModal = exports.AI_TONE_DOT = exports.AI_TONE_CHIP = exports.AI_EMPTY_VALUE = exports.aiStatusTone = exports.isNumericAiFormat = exports.formatAiValue = exports.AiSources = exports.AiSteps = exports.AiDetails = exports.AiTimeline = exports.AiMetrics = exports.AiChart = exports.AiTable = exports.ConnectAskAi = exports.ConnectAppsGrid = exports.isLinkableConnectScreen = exports.matchConnectScreen = exports.filterNavForAudience = exports.gateExemptPaths = exports.isNavItemActive = exports.resolveActivePath = void 0;
|
|
6
|
-
exports.useConnectWorkspaceMembers = exports.useConnectWorkspaces = exports.ConnectWorkspaceSwitcher = exports.resolvePlanGroup = exports.groupPlansByTier = exports.ACTION_LABEL = exports.discountLabel = void 0;
|
|
6
|
+
exports.zonedInstant = exports.dayColumns = exports.layoutBookings = exports.useBookingActions = exports.useBookings = exports.useAvailableSlots = exports.useBookableEventTypes = exports.ConnectBookingDetail = exports.ConnectBookingDialog = exports.ConnectBookingCalendar = exports.useConnectWorkspaceMembers = exports.useConnectWorkspaces = exports.ConnectWorkspaceSwitcher = exports.resolvePlanGroup = exports.groupPlansByTier = exports.ACTION_LABEL = exports.discountLabel = void 0;
|
|
7
7
|
/**
|
|
8
8
|
* Browser half of `@cosmoops/connect-sdk` - React components and hooks.
|
|
9
9
|
*
|
|
@@ -223,3 +223,17 @@ Object.defineProperty(exports, "ConnectWorkspaceSwitcher", { enumerable: true, g
|
|
|
223
223
|
var provider_js_2 = require("./provider.js");
|
|
224
224
|
Object.defineProperty(exports, "useConnectWorkspaces", { enumerable: true, get: function () { return provider_js_2.useConnectWorkspaces; } });
|
|
225
225
|
Object.defineProperty(exports, "useConnectWorkspaceMembers", { enumerable: true, get: function () { return provider_js_2.useConnectWorkspaceMembers; } });
|
|
226
|
+
var booking_calendar_js_1 = require("./components/booking-calendar.js");
|
|
227
|
+
Object.defineProperty(exports, "ConnectBookingCalendar", { enumerable: true, get: function () { return booking_calendar_js_1.ConnectBookingCalendar; } });
|
|
228
|
+
var booking_dialogs_js_1 = require("./components/booking-dialogs.js");
|
|
229
|
+
Object.defineProperty(exports, "ConnectBookingDialog", { enumerable: true, get: function () { return booking_dialogs_js_1.ConnectBookingDialog; } });
|
|
230
|
+
Object.defineProperty(exports, "ConnectBookingDetail", { enumerable: true, get: function () { return booking_dialogs_js_1.ConnectBookingDetail; } });
|
|
231
|
+
var use_scheduling_js_1 = require("./use-scheduling.js");
|
|
232
|
+
Object.defineProperty(exports, "useBookableEventTypes", { enumerable: true, get: function () { return use_scheduling_js_1.useBookableEventTypes; } });
|
|
233
|
+
Object.defineProperty(exports, "useAvailableSlots", { enumerable: true, get: function () { return use_scheduling_js_1.useAvailableSlots; } });
|
|
234
|
+
Object.defineProperty(exports, "useBookings", { enumerable: true, get: function () { return use_scheduling_js_1.useBookings; } });
|
|
235
|
+
Object.defineProperty(exports, "useBookingActions", { enumerable: true, get: function () { return use_scheduling_js_1.useBookingActions; } });
|
|
236
|
+
var booking_grid_js_1 = require("./components/booking-grid.js");
|
|
237
|
+
Object.defineProperty(exports, "layoutBookings", { enumerable: true, get: function () { return booking_grid_js_1.layoutBookings; } });
|
|
238
|
+
Object.defineProperty(exports, "dayColumns", { enumerable: true, get: function () { return booking_grid_js_1.dayColumns; } });
|
|
239
|
+
Object.defineProperty(exports, "zonedInstant", { enumerable: true, get: function () { return booking_grid_js_1.zonedInstant; } });
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.useBookableEventTypes = useBookableEventTypes;
|
|
5
|
+
exports.useAvailableSlots = useAvailableSlots;
|
|
6
|
+
exports.useBookings = useBookings;
|
|
7
|
+
exports.useBookingActions = useBookingActions;
|
|
8
|
+
const react_1 = require("react");
|
|
9
|
+
const provider_js_1 = require("./provider.js");
|
|
10
|
+
/**
|
|
11
|
+
* The bridge's scheduling surface, as hooks.
|
|
12
|
+
*
|
|
13
|
+
* Every path here is one of the six the bridge exposes, and every one of them
|
|
14
|
+
* is resolved *for the signed-in member* - Connect takes the workspace from
|
|
15
|
+
* their session and checks their own `calendar:read` / `calendar:manage`.
|
|
16
|
+
* Nothing in this file names a workspace, and nothing should: a browser that
|
|
17
|
+
* can name a tenant is a browser that can read someone else's diary.
|
|
18
|
+
*/
|
|
19
|
+
/** What this workspace offers as bookable. Active types only. */
|
|
20
|
+
function useBookableEventTypes() {
|
|
21
|
+
const { data, isLoading, error } = (0, provider_js_1.useConnectResource)("/scheduling/event-types");
|
|
22
|
+
return { eventTypes: data?.eventTypes ?? [], isLoading, error };
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Free times for one appointment type.
|
|
26
|
+
*
|
|
27
|
+
* **Deliberately re-read rather than cached across a session.** A slot list is
|
|
28
|
+
* a snapshot of contention: anyone else on that calendar - a colleague, a
|
|
29
|
+
* voice agent mid-call, someone on the public booking link - can take one
|
|
30
|
+
* between the read and the click. Showing a stale one is how two people are
|
|
31
|
+
* offered the same minute. The booking call re-checks inside a transaction
|
|
32
|
+
* anyway, so a stale list costs a refusal rather than a double booking; this
|
|
33
|
+
* just means the refusal is rare.
|
|
34
|
+
*/
|
|
35
|
+
function useAvailableSlots(query) {
|
|
36
|
+
const path = query
|
|
37
|
+
? `/scheduling/slots?${new URLSearchParams({
|
|
38
|
+
eventTypeId: query.eventTypeId,
|
|
39
|
+
from: query.from.toISOString(),
|
|
40
|
+
to: query.to.toISOString(),
|
|
41
|
+
...(query.excludeBookingId ? { excludeBookingId: query.excludeBookingId } : {}),
|
|
42
|
+
}).toString()}`
|
|
43
|
+
: null;
|
|
44
|
+
// `useConnectResource` needs a path; an empty one never resolves, so the
|
|
45
|
+
// hook is called unconditionally (rules of hooks) and simply idles.
|
|
46
|
+
const { data, isLoading, error, refetch } = (0, provider_js_1.useConnectResource)(path ?? "");
|
|
47
|
+
return {
|
|
48
|
+
slots: path ? (data?.slots ?? []) : [],
|
|
49
|
+
timezone: data?.timezone ?? null,
|
|
50
|
+
durationMinutes: data?.durationMinutes ?? null,
|
|
51
|
+
isLoading: path ? isLoading : false,
|
|
52
|
+
error: path ? error : null,
|
|
53
|
+
refetch,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** One page of the diary, ascending - a forward view, not a history. */
|
|
57
|
+
function useBookings(query) {
|
|
58
|
+
const path = (0, react_1.useMemo)(() => `/scheduling/bookings?${new URLSearchParams({
|
|
59
|
+
from: query.from.toISOString(),
|
|
60
|
+
to: query.to.toISOString(),
|
|
61
|
+
order: "asc",
|
|
62
|
+
status: "confirmed",
|
|
63
|
+
limit: String(query.limit ?? 100),
|
|
64
|
+
...(query.hostMemberId ? { hostMemberId: query.hostMemberId } : {}),
|
|
65
|
+
}).toString()}`, [query.from, query.to, query.hostMemberId, query.limit]);
|
|
66
|
+
const { data, isLoading, error, refetch } = (0, provider_js_1.useConnectResource)(path);
|
|
67
|
+
return {
|
|
68
|
+
bookings: data?.bookings ?? [],
|
|
69
|
+
hasMore: data?.hasMore ?? false,
|
|
70
|
+
isLoading,
|
|
71
|
+
error,
|
|
72
|
+
path,
|
|
73
|
+
refetch,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Book, move and cancel.
|
|
78
|
+
*
|
|
79
|
+
* **None of these is optimistic**, and that is not an oversight. Whether a
|
|
80
|
+
* slot is still free is a question only the server can answer, inside the
|
|
81
|
+
* transaction that decides it - two people can be holding the same minute
|
|
82
|
+
* right now. Painting a booking as made and withdrawing it is exactly the lie
|
|
83
|
+
* the two-phase hold exists to prevent.
|
|
84
|
+
*/
|
|
85
|
+
function useBookingActions() {
|
|
86
|
+
const { mutateResource } = (0, provider_js_1.useConnect)();
|
|
87
|
+
const [isPending, setPending] = (0, react_1.useState)(false);
|
|
88
|
+
const [error, setError] = (0, react_1.useState)(null);
|
|
89
|
+
const run = (0, react_1.useCallback)(async (path, body) => {
|
|
90
|
+
setPending(true);
|
|
91
|
+
setError(null);
|
|
92
|
+
try {
|
|
93
|
+
return await mutateResource(path, body);
|
|
94
|
+
}
|
|
95
|
+
catch (cause) {
|
|
96
|
+
// The server writes these as sentences - "that time was just taken" -
|
|
97
|
+
// precisely so they can be shown rather than mapped to a code.
|
|
98
|
+
setError(cause instanceof Error ? cause.message : "Something went wrong.");
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
setPending(false);
|
|
103
|
+
}
|
|
104
|
+
}, [mutateResource]);
|
|
105
|
+
return {
|
|
106
|
+
isPending,
|
|
107
|
+
error,
|
|
108
|
+
clearError: () => setError(null),
|
|
109
|
+
createBooking: (input) => run("/scheduling/bookings", input),
|
|
110
|
+
rescheduleBooking: (input) => run("/scheduling/bookings/reschedule", input).then((result) => result?.booking ?? null),
|
|
111
|
+
cancelBooking: (input) => run("/scheduling/bookings/cancel", input),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -7,6 +7,7 @@ const ai_js_1 = require("./resources/ai.js");
|
|
|
7
7
|
const data_js_1 = require("./resources/data.js");
|
|
8
8
|
const workspaces_js_1 = require("./resources/workspaces.js");
|
|
9
9
|
const scheduling_js_1 = require("./resources/scheduling.js");
|
|
10
|
+
const contacts_js_1 = require("./resources/contacts.js");
|
|
10
11
|
const kv_js_1 = require("./resources/kv.js");
|
|
11
12
|
const billing_js_1 = require("./resources/billing.js");
|
|
12
13
|
const drive_js_1 = require("./resources/drive.js");
|
|
@@ -82,6 +83,8 @@ class ConnectClient {
|
|
|
82
83
|
drive;
|
|
83
84
|
/** A subscribing workspace's own calendar - what is bookable, free, and booked. */
|
|
84
85
|
scheduling;
|
|
86
|
+
/** Who a workspace already knows how to reach, by phone or by id. */
|
|
87
|
+
contacts;
|
|
85
88
|
/** RAG chat and structured extraction. */
|
|
86
89
|
ai;
|
|
87
90
|
/** Your application's own document store - see `DataResource`. */
|
|
@@ -131,6 +134,7 @@ class ConnectClient {
|
|
|
131
134
|
this.billing = new billing_js_1.BillingResource(transport);
|
|
132
135
|
this.drive = new drive_js_1.DriveResource(transport);
|
|
133
136
|
this.scheduling = new scheduling_js_1.SchedulingResource(transport);
|
|
137
|
+
this.contacts = new contacts_js_1.ContactsResource(transport);
|
|
134
138
|
this.ai = new ai_js_1.AiResource(transport);
|
|
135
139
|
this.data = new data_js_1.DataResource(transport);
|
|
136
140
|
this.kv = new kv_js_1.KvResource(transport);
|
|
@@ -146,4 +150,4 @@ class ConnectClient {
|
|
|
146
150
|
}
|
|
147
151
|
exports.ConnectClient = ConnectClient;
|
|
148
152
|
/** Kept in step with `package.json` by `sdk.test.ts`. */
|
|
149
|
-
exports.SDK_VERSION = "0.
|
|
153
|
+
exports.SDK_VERSION = "0.19.0";
|
|
@@ -415,6 +415,142 @@ function createConnectHandler(options) {
|
|
|
415
415
|
await session.write?.(request, response, result.sessionToken);
|
|
416
416
|
return response;
|
|
417
417
|
},
|
|
418
|
+
/*
|
|
419
|
+
* Scheduling - the workspace's own calendar, in a browser.
|
|
420
|
+
*
|
|
421
|
+
* Every one of these forwards the caller's session token, which is what
|
|
422
|
+
* makes them safe to expose: Connect derives the workspace from that token
|
|
423
|
+
* and checks the member's own `calendar:read` / `calendar:manage`. The
|
|
424
|
+
* browser names no tenant and the partner's key does not, by itself,
|
|
425
|
+
* authorize a write - without that second half, shipping a booking
|
|
426
|
+
* component would let every end user of every partner app take slots on a
|
|
427
|
+
* real business calendar.
|
|
428
|
+
*
|
|
429
|
+
* `workspaceId` is still passed because the resource requires it, and is
|
|
430
|
+
* still ignored by Connect whenever a token is present. `requireWorkspace`
|
|
431
|
+
* supplies the session's own, so the two can never disagree.
|
|
432
|
+
*/
|
|
433
|
+
"GET /scheduling/event-types": async (request) => {
|
|
434
|
+
const { workspaceId } = await requireWorkspace(request);
|
|
435
|
+
const asUser = await requireToken(request);
|
|
436
|
+
return json({ eventTypes: await client.scheduling.listEventTypes(workspaceId, { asUser }) });
|
|
437
|
+
},
|
|
438
|
+
"GET /scheduling/slots": async (request) => {
|
|
439
|
+
const { workspaceId } = await requireWorkspace(request);
|
|
440
|
+
const asUser = await requireToken(request);
|
|
441
|
+
const url = new URL(request.url);
|
|
442
|
+
const eventTypeId = url.searchParams.get("eventTypeId");
|
|
443
|
+
const from = url.searchParams.get("from");
|
|
444
|
+
const to = url.searchParams.get("to");
|
|
445
|
+
if (!eventTypeId || !from || !to) {
|
|
446
|
+
throw new errors_js_1.ConnectError({
|
|
447
|
+
status: 400,
|
|
448
|
+
code: "validation_error",
|
|
449
|
+
message: "eventTypeId, from and to are required",
|
|
450
|
+
endpoint: "/scheduling/slots",
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
return json(await client.scheduling.findSlots({
|
|
454
|
+
workspaceId,
|
|
455
|
+
eventTypeId,
|
|
456
|
+
from,
|
|
457
|
+
to,
|
|
458
|
+
// Set when offering times to move an existing appointment to, or the
|
|
459
|
+
// booking blocks its own replacement and the list comes back empty.
|
|
460
|
+
excludeBookingId: url.searchParams.get("excludeBookingId") ?? undefined,
|
|
461
|
+
asUser,
|
|
462
|
+
}));
|
|
463
|
+
},
|
|
464
|
+
"GET /scheduling/bookings": async (request) => {
|
|
465
|
+
const { workspaceId } = await requireWorkspace(request);
|
|
466
|
+
const asUser = await requireToken(request);
|
|
467
|
+
const url = new URL(request.url);
|
|
468
|
+
const limit = Number(url.searchParams.get("limit") ?? 25);
|
|
469
|
+
return json(await client.scheduling.listBookings({
|
|
470
|
+
workspaceId,
|
|
471
|
+
status: url.searchParams.get("status") ?? undefined,
|
|
472
|
+
hostMemberId: url.searchParams.get("hostMemberId") ?? undefined,
|
|
473
|
+
from: url.searchParams.get("from") ?? undefined,
|
|
474
|
+
to: url.searchParams.get("to") ?? undefined,
|
|
475
|
+
order: url.searchParams.get("order") === "asc" ? "asc" : "desc",
|
|
476
|
+
cursor: url.searchParams.get("cursor") ?? undefined,
|
|
477
|
+
limit: Number.isInteger(limit) && limit >= 1 && limit <= 100 ? limit : 25,
|
|
478
|
+
asUser,
|
|
479
|
+
}));
|
|
480
|
+
},
|
|
481
|
+
"POST /scheduling/bookings": async (request) => {
|
|
482
|
+
const { workspaceId } = await requireWorkspace(request);
|
|
483
|
+
const asUser = await requireToken(request);
|
|
484
|
+
const body = (await request.json().catch(() => null));
|
|
485
|
+
if (typeof body?.eventTypeId !== "string" ||
|
|
486
|
+
typeof body.startAt !== "string" ||
|
|
487
|
+
typeof body.attendee?.name !== "string" ||
|
|
488
|
+
typeof body.attendee?.phone !== "string") {
|
|
489
|
+
throw new errors_js_1.ConnectError({
|
|
490
|
+
status: 400,
|
|
491
|
+
code: "validation_error",
|
|
492
|
+
message: "eventTypeId, startAt and an attendee name and phone are required",
|
|
493
|
+
endpoint: "/scheduling/bookings",
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
return json(await client.scheduling.createBooking({
|
|
497
|
+
workspaceId,
|
|
498
|
+
eventTypeId: body.eventTypeId,
|
|
499
|
+
startAt: body.startAt,
|
|
500
|
+
attendee: {
|
|
501
|
+
name: body.attendee.name,
|
|
502
|
+
phone: body.attendee.phone,
|
|
503
|
+
email: typeof body.attendee.email === "string" ? body.attendee.email : null,
|
|
504
|
+
},
|
|
505
|
+
notes: typeof body.notes === "string" ? body.notes : null,
|
|
506
|
+
asUser,
|
|
507
|
+
}));
|
|
508
|
+
},
|
|
509
|
+
/*
|
|
510
|
+
* Reschedule and cancel take the booking id in the body rather than the
|
|
511
|
+
* path, matching `POST /sessions/revoke`. The bridge's route table is
|
|
512
|
+
* exact-match on `METHOD /path`, so a path parameter would mean a second
|
|
513
|
+
* matching strategy for two routes.
|
|
514
|
+
*/
|
|
515
|
+
"POST /scheduling/bookings/reschedule": async (request) => {
|
|
516
|
+
const { workspaceId } = await requireWorkspace(request);
|
|
517
|
+
const asUser = await requireToken(request);
|
|
518
|
+
const body = (await request.json().catch(() => null));
|
|
519
|
+
if (typeof body?.bookingId !== "string" || typeof body.startAt !== "string") {
|
|
520
|
+
throw new errors_js_1.ConnectError({
|
|
521
|
+
status: 400,
|
|
522
|
+
code: "validation_error",
|
|
523
|
+
message: "bookingId and startAt are required",
|
|
524
|
+
endpoint: "/scheduling/bookings/reschedule",
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
return json(await client.scheduling.rescheduleBooking({
|
|
528
|
+
workspaceId,
|
|
529
|
+
bookingId: body.bookingId,
|
|
530
|
+
startAt: body.startAt,
|
|
531
|
+
notes: typeof body.notes === "string" ? body.notes : undefined,
|
|
532
|
+
asUser,
|
|
533
|
+
}));
|
|
534
|
+
},
|
|
535
|
+
"POST /scheduling/bookings/cancel": async (request) => {
|
|
536
|
+
const { workspaceId } = await requireWorkspace(request);
|
|
537
|
+
const asUser = await requireToken(request);
|
|
538
|
+
const body = (await request.json().catch(() => null));
|
|
539
|
+
if (typeof body?.bookingId !== "string") {
|
|
540
|
+
throw new errors_js_1.ConnectError({
|
|
541
|
+
status: 400,
|
|
542
|
+
code: "validation_error",
|
|
543
|
+
message: "bookingId is required",
|
|
544
|
+
endpoint: "/scheduling/bookings/cancel",
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
return json(await client.scheduling.cancelBooking({
|
|
548
|
+
workspaceId,
|
|
549
|
+
bookingId: body.bookingId,
|
|
550
|
+
reason: typeof body.reason === "string" ? body.reason : null,
|
|
551
|
+
asUser,
|
|
552
|
+
}));
|
|
553
|
+
},
|
|
418
554
|
"GET /integrations": async (request) => {
|
|
419
555
|
const { workspaceId } = await requireWorkspace(request);
|
|
420
556
|
return json({ toolkits: await client.integrations.list(workspaceId) });
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ContactsResource = void 0;
|
|
4
|
+
const http_js_1 = require("../http.js");
|
|
5
|
+
const acting_as_js_1 = require("./acting-as.js");
|
|
6
|
+
/**
|
|
7
|
+
* Who a workspace already knows how to reach.
|
|
8
|
+
*
|
|
9
|
+
* **This is the telephony identity, not a CRM record.** A contact is keyed by
|
|
10
|
+
* `primaryPhone` - the one fact every inbound call carries - and it is written
|
|
11
|
+
* automatically: a voice agent creates one on the first call from a new number
|
|
12
|
+
* and fills fields in as it learns them. `source` says which kind you are
|
|
13
|
+
* holding, and it is worth rendering: `call` means an agent guessed and nobody
|
|
14
|
+
* has confirmed, `manual` means a person typed it.
|
|
15
|
+
*
|
|
16
|
+
* **Reference it, do not copy it.** If your application keeps its own customer
|
|
17
|
+
* records, store this contact's `id` on yours rather than duplicating the
|
|
18
|
+
* fields. Two writable copies of a person's name with no single owner drift,
|
|
19
|
+
* and Connect's is the one an agent keeps updating from calls. The id is also
|
|
20
|
+
* an exact join to anything else keyed by it - `Booking.contactId`, most
|
|
21
|
+
* usefully - so "which of my records is this appointment for" becomes a lookup
|
|
22
|
+
* instead of a phone match.
|
|
23
|
+
*
|
|
24
|
+
* Read-only for now. Writing into a store with a per-field provenance chain
|
|
25
|
+
* means declaring where your facts came from and how they merge with a
|
|
26
|
+
* human's, and nothing needs that yet.
|
|
27
|
+
*/
|
|
28
|
+
class ContactsResource {
|
|
29
|
+
transport;
|
|
30
|
+
constructor(transport) {
|
|
31
|
+
this.transport = transport;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* "Do we already know this number?" - one point read, cheap enough for a
|
|
35
|
+
* screen pop on every inbound call.
|
|
36
|
+
*
|
|
37
|
+
* Resolves to `null` for a number nobody has called from before. That is the
|
|
38
|
+
* ordinary case for a first-time caller, not an error, which is why it is a
|
|
39
|
+
* null rather than a thrown `ConnectError`.
|
|
40
|
+
*/
|
|
41
|
+
async lookupByPhone(input) {
|
|
42
|
+
const result = await (0, http_js_1.request)(this.transport, {
|
|
43
|
+
method: "GET",
|
|
44
|
+
path: "/contacts",
|
|
45
|
+
query: { workspaceId: input.workspaceId, phone: input.phone },
|
|
46
|
+
headers: (0, acting_as_js_1.actingAs)(input.asUser),
|
|
47
|
+
});
|
|
48
|
+
return result.contact ?? null;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The record behind an id you already hold.
|
|
52
|
+
*
|
|
53
|
+
* Throws `ConnectError` with `code: "not_found"` for an id that does not
|
|
54
|
+
* exist *or* belongs to another workspace - deliberately the same answer,
|
|
55
|
+
* since telling those apart would confirm a contact exists somewhere else.
|
|
56
|
+
*/
|
|
57
|
+
get(contactId, options) {
|
|
58
|
+
return (0, http_js_1.request)(this.transport, {
|
|
59
|
+
method: "GET",
|
|
60
|
+
path: `/contacts/${encodeURIComponent(contactId)}`,
|
|
61
|
+
query: { workspaceId: options.workspaceId },
|
|
62
|
+
headers: (0, acting_as_js_1.actingAs)(options.asUser),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
exports.ContactsResource = ContactsResource;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.SchedulingResource = 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
|
* A subscribing workspace's own calendar.
|
|
7
8
|
*
|
|
@@ -21,11 +22,12 @@ class SchedulingResource {
|
|
|
21
22
|
this.transport = transport;
|
|
22
23
|
}
|
|
23
24
|
/** What this workspace offers as bookable. Active appointment types only. */
|
|
24
|
-
async listEventTypes(workspaceId) {
|
|
25
|
+
async listEventTypes(workspaceId, options = {}) {
|
|
25
26
|
const result = await (0, http_js_1.request)(this.transport, {
|
|
26
27
|
method: "GET",
|
|
27
28
|
path: "/scheduling/event-types",
|
|
28
29
|
query: { workspaceId },
|
|
30
|
+
headers: (0, acting_as_js_1.actingAs)(options.asUser),
|
|
29
31
|
});
|
|
30
32
|
// `?? []` for the same reason `apps.list` has it - a collection every
|
|
31
33
|
// caller immediately maps, and a body without the key would hand them
|
|
@@ -54,7 +56,9 @@ class SchedulingResource {
|
|
|
54
56
|
eventTypeId: input.eventTypeId,
|
|
55
57
|
from: toInstant(input.from),
|
|
56
58
|
to: toInstant(input.to),
|
|
59
|
+
excludeBookingId: input.excludeBookingId,
|
|
57
60
|
},
|
|
61
|
+
headers: (0, acting_as_js_1.actingAs)(input.asUser),
|
|
58
62
|
});
|
|
59
63
|
}
|
|
60
64
|
/** One page of the workspace's diary. */
|
|
@@ -72,6 +76,7 @@ class SchedulingResource {
|
|
|
72
76
|
cursor: input.cursor,
|
|
73
77
|
limit: input.limit === undefined ? undefined : String(input.limit),
|
|
74
78
|
},
|
|
79
|
+
headers: (0, acting_as_js_1.actingAs)(input.asUser),
|
|
75
80
|
});
|
|
76
81
|
}
|
|
77
82
|
/**
|
|
@@ -93,6 +98,31 @@ class SchedulingResource {
|
|
|
93
98
|
attendee: input.attendee,
|
|
94
99
|
notes: input.notes ?? null,
|
|
95
100
|
},
|
|
101
|
+
headers: (0, acting_as_js_1.actingAs)(input.asUser),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Moves an appointment to a different time.
|
|
106
|
+
*
|
|
107
|
+
* **The original is retired, not edited.** It stays on the diary as
|
|
108
|
+
* `rescheduled` with `rescheduledToId` pointing at the replacement, and both
|
|
109
|
+
* come back here - so a booking id you cached still resolves, and still says
|
|
110
|
+
* where the appointment went.
|
|
111
|
+
*
|
|
112
|
+
* A refused move changes nothing: the new slot is claimed before the old one
|
|
113
|
+
* is released, so a `conflict` leaves the customer with the appointment they
|
|
114
|
+
* already had. Re-read the slots (with `excludeBookingId`) and offer another.
|
|
115
|
+
*/
|
|
116
|
+
rescheduleBooking(input) {
|
|
117
|
+
return (0, http_js_1.request)(this.transport, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
path: `/scheduling/bookings/${encodeURIComponent(input.bookingId)}/reschedule`,
|
|
120
|
+
json: {
|
|
121
|
+
workspaceId: input.workspaceId,
|
|
122
|
+
startAt: toInstant(input.startAt),
|
|
123
|
+
notes: input.notes ?? null,
|
|
124
|
+
},
|
|
125
|
+
headers: (0, acting_as_js_1.actingAs)(input.asUser),
|
|
96
126
|
});
|
|
97
127
|
}
|
|
98
128
|
/**
|
|
@@ -107,6 +137,7 @@ class SchedulingResource {
|
|
|
107
137
|
method: "POST",
|
|
108
138
|
path: `/scheduling/bookings/${encodeURIComponent(input.bookingId)}/cancel`,
|
|
109
139
|
json: { workspaceId: input.workspaceId, reason: input.reason ?? null },
|
|
140
|
+
headers: (0, acting_as_js_1.actingAs)(input.asUser),
|
|
110
141
|
});
|
|
111
142
|
}
|
|
112
143
|
}
|