@cosmoops/connect-sdk 0.17.0 → 0.18.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 +82 -0
- package/README.md +19 -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 +1 -1
- package/dist/cjs/server/handler.js +136 -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 +1 -1
- package/dist/esm/server/handler.js +136 -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 +1 -1
- package/dist/types/server/index.d.ts +1 -1
- package/dist/types/server/resources/scheduling.d.ts +51 -6
- package/dist/types/types.d.ts +13 -0
- package/package.json +1 -1
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
|
+
}
|
|
@@ -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) });
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { Button, Card, Chip, Skeleton } from "@heroui/react";
|
|
4
|
+
import { CalendarDays, ChevronLeft, ChevronRight, Phone, Video } from "lucide-react";
|
|
5
|
+
import { useMemo, useState } from "react";
|
|
6
|
+
import { ConnectEmptyState } from "../../ui/empty-state.js";
|
|
7
|
+
import { useBookableEventTypes, useBookings, } from "../use-scheduling.js";
|
|
8
|
+
import { dayColumns, dayStartInstant, hourMarks, layoutBookings, zonedInstant, } from "./booking-grid.js";
|
|
9
|
+
import { ConnectBookingDetail, ConnectBookingDialog } from "./booking-dialogs.js";
|
|
10
|
+
/**
|
|
11
|
+
* The visible band of the day. Outside it a booking is drawn at the edge and
|
|
12
|
+
* flagged rather than hidden - see `layoutBookings`.
|
|
13
|
+
*
|
|
14
|
+
* 07:00-21:00 rather than a full day: fourteen hours is the most that stays
|
|
15
|
+
* legible on a laptop without scrolling, and a booking grid whose first screen
|
|
16
|
+
* is six empty night-time hours reads as a broken calendar.
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULT_WINDOW = { startMinute: 7 * 60, endMinute: 21 * 60 };
|
|
19
|
+
const LOCATION_ICON = {
|
|
20
|
+
phone: Phone,
|
|
21
|
+
address: CalendarDays,
|
|
22
|
+
link: Video,
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* A week of the workspace's calendar, with booking, rescheduling and
|
|
26
|
+
* cancelling.
|
|
27
|
+
*
|
|
28
|
+
* **Two views, and which one you get is a viewport decision, not a
|
|
29
|
+
* preference.** A seven-column time grid needs roughly 640px before a column
|
|
30
|
+
* is wide enough to hold a name; below that it degrades into unreadable
|
|
31
|
+
* slivers. So the grid is `sm:` and up, and the same week renders as a grouped
|
|
32
|
+
* agenda underneath - not a cut-down grid, a different and better shape for a
|
|
33
|
+
* phone. Both read the same data and open the same dialogs.
|
|
34
|
+
*
|
|
35
|
+
* Everything it does goes through the bridge as the signed-in member, so
|
|
36
|
+
* Connect checks *their* `calendar:read` / `calendar:manage` rather than the
|
|
37
|
+
* host application's key. A member who may look but not book gets the grid and
|
|
38
|
+
* a refusal on the write, which is the correct shape - see `useBookingActions`.
|
|
39
|
+
*/
|
|
40
|
+
export function ConnectBookingCalendar({ timeZone, hostMemberId, window: visibleWindow = DEFAULT_WINDOW, canBook = true, className, }) {
|
|
41
|
+
const zone = timeZone ?? hostTimeZone();
|
|
42
|
+
const [weekAnchor, setWeekAnchor] = useState(() => startOfWeek(new Date(), zone));
|
|
43
|
+
const [selected, setSelected] = useState(null);
|
|
44
|
+
const [booking, setBooking] = useState(null);
|
|
45
|
+
const now = new Date();
|
|
46
|
+
const columns = useMemo(() => dayColumns(weekAnchor, zone, 7, now),
|
|
47
|
+
// `now` is deliberately not a dependency: it changes on every render, and
|
|
48
|
+
// the only thing it decides is which column is highlighted. Recomputing
|
|
49
|
+
// seven columns per keystroke elsewhere on the page to move a highlight
|
|
50
|
+
// that changes once a day is not a trade worth making.
|
|
51
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
52
|
+
[weekAnchor, zone]);
|
|
53
|
+
const range = useMemo(() => ({
|
|
54
|
+
from: columns[0]?.startsAt ?? weekAnchor,
|
|
55
|
+
to: new Date((columns.at(-1)?.startsAt.getTime() ?? weekAnchor.getTime()) + 24 * 60 * 60_000),
|
|
56
|
+
}), [columns, weekAnchor]);
|
|
57
|
+
const { eventTypes } = useBookableEventTypes();
|
|
58
|
+
const { bookings, isLoading, error, refetch } = useBookings({
|
|
59
|
+
from: range.from,
|
|
60
|
+
to: range.to,
|
|
61
|
+
hostMemberId,
|
|
62
|
+
});
|
|
63
|
+
const positioned = useMemo(() => layoutBookings(bookings, zone, visibleWindow), [bookings, zone, visibleWindow]);
|
|
64
|
+
const marks = useMemo(() => hourMarks(visibleWindow), [visibleWindow]);
|
|
65
|
+
const shiftWeek = (weeks) => setWeekAnchor((anchor) => new Date(anchor.getTime() + weeks * 7 * 24 * 60 * 60_000));
|
|
66
|
+
const eventTypeById = useMemo(() => new Map(eventTypes.map((eventType) => [eventType.id, eventType])), [eventTypes]);
|
|
67
|
+
return (_jsxs("div", { className: `flex flex-col gap-4 ${className ?? ""}`, children: [_jsxs("header", { className: "flex flex-wrap items-center justify-between gap-3", children: [_jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Previous week", onPress: () => shiftWeek(-1), children: _jsx(ChevronLeft, { className: "size-4", "aria-hidden": true }) }), _jsx(Button, { size: "sm", variant: "secondary", onPress: () => setWeekAnchor(startOfWeek(new Date(), zone)), children: "Today" }), _jsx(Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Next week", onPress: () => shiftWeek(1), children: _jsx(ChevronRight, { className: "size-4", "aria-hidden": true }) }), _jsx("p", { className: "ml-2 text-sm font-medium", children: weekLabel(columns, zone) })] }), canBook && eventTypes.length > 0 && (_jsx(Button, { size: "sm", variant: "primary", onPress: () => setBooking({ startAt: nextHour(now) }), children: "New appointment" }))] }), error && _jsx("p", { className: "text-sm text-danger", children: error }), isLoading ? (_jsx(Skeleton, { className: "h-96 w-full rounded-lg" })) : (_jsxs(_Fragment, { children: [_jsx(Card, { className: "hidden overflow-hidden sm:block", children: _jsx(WeekGrid, { columns: columns, marks: marks, window: visibleWindow, positioned: positioned, eventTypeById: eventTypeById, zone: zone, onSelect: setSelected, onPickEmpty: canBook ? (startAt) => setBooking({ startAt }) : undefined }) }), _jsx("div", { className: "sm:hidden", children: _jsx(AgendaView, { columns: columns, bookings: bookings, eventTypeById: eventTypeById, zone: zone, onSelect: setSelected }) })] })), booking && (_jsx(ConnectBookingDialog, { isOpen: true, onOpenChange: (open) => !open && setBooking(null), eventTypes: eventTypes, initialStartAt: booking.startAt, timeZone: zone, onBooked: () => {
|
|
68
|
+
setBooking(null);
|
|
69
|
+
refetch();
|
|
70
|
+
} })), selected && (_jsx(ConnectBookingDetail, { booking: selected, eventType: eventTypeById.get(selected.eventTypeId) ?? null, timeZone: zone, onOpenChange: (open) => !open && setSelected(null), onChanged: () => {
|
|
71
|
+
setSelected(null);
|
|
72
|
+
refetch();
|
|
73
|
+
} }))] }));
|
|
74
|
+
}
|
|
75
|
+
function WeekGrid({ columns, marks, window: visibleWindow, positioned, eventTypeById, zone, onSelect, onPickEmpty, }) {
|
|
76
|
+
const span = visibleWindow.endMinute - visibleWindow.startMinute;
|
|
77
|
+
return (_jsxs("div", { className: "grid grid-cols-[3rem_repeat(7,minmax(0,1fr))]", children: [_jsx("div", { className: "border-b border-separator" }), columns.map((column) => (_jsx("div", { className: `border-b border-l border-separator px-2 py-2 text-center ${column.isToday ? "bg-accent/5" : ""}`, children: _jsx("p", { className: `text-xs font-medium ${column.isToday ? "text-accent" : ""}`, children: column.label }) }, column.dateKey))), _jsx("div", { className: "relative", style: { height: `${(span / 60) * 3}rem` }, children: marks.map((mark) => (_jsx("span", { className: "absolute right-1 -translate-y-1/2 text-[0.625rem] text-muted", style: { top: `${((mark.minute - visibleWindow.startMinute) / span) * 100}%` }, children: mark.label }, mark.minute))) }), columns.map((column) => (_jsxs("div", { className: `relative border-l border-separator ${column.isToday ? "bg-accent/5" : ""}`, style: { height: `${(span / 60) * 3}rem` }, children: [marks.map((mark) => (_jsx("div", { className: "absolute inset-x-0 border-t border-separator/60", style: { top: `${((mark.minute - visibleWindow.startMinute) / span) * 100}%` } }, mark.minute))), onPickEmpty &&
|
|
78
|
+
marks.map((mark) => (_jsx("button", { type: "button", "aria-label": `Book ${column.label} at ${mark.label}`, className: "absolute inset-x-0 cursor-pointer transition-colors hover:bg-accent/10", style: {
|
|
79
|
+
top: `${((mark.minute - visibleWindow.startMinute) / span) * 100}%`,
|
|
80
|
+
height: `${(60 / span) * 100}%`,
|
|
81
|
+
}, onClick: () => onPickEmpty(new Date(column.startsAt.getTime() + mark.minute * 60_000)) }, `slot-${mark.minute}`))), positioned
|
|
82
|
+
.filter((entry) => entry.dateKey === column.dateKey)
|
|
83
|
+
.map((entry) => {
|
|
84
|
+
const eventType = eventTypeById.get(entry.booking.eventTypeId);
|
|
85
|
+
return (_jsxs("button", { type: "button", onClick: () => onSelect(entry.booking), className: "absolute overflow-hidden rounded-[--radius] border border-accent/30 bg-accent/15 px-1 py-0.5 text-left transition-colors hover:bg-accent/25", style: {
|
|
86
|
+
top: `${entry.top * 100}%`,
|
|
87
|
+
height: `${entry.height * 100}%`,
|
|
88
|
+
// Side-by-side within an overlap cluster, with a hairline
|
|
89
|
+
// gap so two adjacent blocks read as two things.
|
|
90
|
+
left: `${(entry.column / entry.columns) * 100}%`,
|
|
91
|
+
width: `calc(${(1 / entry.columns) * 100}% - 2px)`,
|
|
92
|
+
}, children: [_jsx("span", { className: "block truncate text-[0.625rem] font-medium text-accent", children: timeLabel(new Date(entry.booking.startAt), zone) }), _jsx("span", { className: "block truncate text-[0.625rem]", children: entry.booking.attendee.name }), eventType && (_jsx("span", { className: "block truncate text-[0.625rem] text-muted", children: eventType.name }))] }, entry.booking.id));
|
|
93
|
+
})] }, column.dateKey)))] }));
|
|
94
|
+
}
|
|
95
|
+
function AgendaView({ columns, bookings, eventTypeById, zone, onSelect, }) {
|
|
96
|
+
const byDay = useMemo(() => {
|
|
97
|
+
const map = new Map();
|
|
98
|
+
for (const booking of bookings) {
|
|
99
|
+
const { dateKey } = zonedInstant(new Date(booking.startAt), zone);
|
|
100
|
+
map.set(dateKey, [...(map.get(dateKey) ?? []), booking]);
|
|
101
|
+
}
|
|
102
|
+
return map;
|
|
103
|
+
}, [bookings, zone]);
|
|
104
|
+
// Days with nothing on them are omitted rather than rendered empty: an
|
|
105
|
+
// agenda is a list of what is happening, and seven "nothing" rows is six
|
|
106
|
+
// screens of scrolling to find the one day that matters.
|
|
107
|
+
const populated = columns.filter((column) => (byDay.get(column.dateKey)?.length ?? 0) > 0);
|
|
108
|
+
if (populated.length === 0) {
|
|
109
|
+
return (_jsx(ConnectEmptyState, { variant: "dashed", icon: _jsx(CalendarDays, { "aria-hidden": true }), title: "Nothing booked this week", description: "Appointments booked here, on your public link, or by a voice agent all show up together." }));
|
|
110
|
+
}
|
|
111
|
+
return (_jsx("div", { className: "flex flex-col gap-4", children: populated.map((column) => (_jsxs("section", { className: "flex flex-col gap-2", children: [_jsx("h3", { className: `text-sm font-medium ${column.isToday ? "text-accent" : ""}`, children: column.label }), (byDay.get(column.dateKey) ?? []).map((booking) => {
|
|
112
|
+
const eventType = eventTypeById.get(booking.eventTypeId);
|
|
113
|
+
const Icon = eventType ? LOCATION_ICON[eventType.locationKind] : CalendarDays;
|
|
114
|
+
return (_jsx("button", { type: "button", onClick: () => onSelect(booking), children: _jsx(Card, { className: "transition-colors hover:bg-surface", children: _jsxs(Card.Content, { className: "flex items-center gap-3 py-3 text-left", children: [_jsx(Icon, { className: "size-4 shrink-0 text-muted", "aria-hidden": true }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "truncate text-sm font-medium", children: booking.attendee.name }), _jsxs("p", { className: "truncate text-xs text-muted", children: [timeLabel(new Date(booking.startAt), zone), eventType ? ` · ${eventType.name}` : ""] })] }), booking.source === "agent-call" && (_jsxs(Chip, { size: "sm", variant: "secondary", children: [_jsx(Phone, { className: "size-3", "aria-hidden": true }), "Agent"] }))] }) }) }, booking.id));
|
|
115
|
+
})] }, column.dateKey))) }));
|
|
116
|
+
}
|
|
117
|
+
/* ------------------------------------------------------------------ helpers */
|
|
118
|
+
/** The host's zone, used when the caller does not name the business's. */
|
|
119
|
+
function hostTimeZone() {
|
|
120
|
+
try {
|
|
121
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return "UTC";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Monday of the week `instant` falls in, in `zone`.
|
|
129
|
+
*
|
|
130
|
+
* Monday rather than Sunday: this is a *business* calendar, and a week that
|
|
131
|
+
* splits the working days across two screens is the one thing a week view must
|
|
132
|
+
* not do.
|
|
133
|
+
*/
|
|
134
|
+
function startOfWeek(instant, zone) {
|
|
135
|
+
const { dateKey } = zonedInstant(instant, zone);
|
|
136
|
+
const midnight = dayStartInstant(dateKey, zone);
|
|
137
|
+
// `getUTCDay` on local midnight-as-an-instant is not the local weekday, so
|
|
138
|
+
// the weekday is read back through the zone.
|
|
139
|
+
const weekday = new Intl.DateTimeFormat("en-US", { timeZone: zone, weekday: "short" }).format(midnight);
|
|
140
|
+
const offset = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].indexOf(weekday);
|
|
141
|
+
return new Date(midnight.getTime() - (offset < 0 ? 0 : offset) * 24 * 60 * 60_000);
|
|
142
|
+
}
|
|
143
|
+
function weekLabel(columns, zone) {
|
|
144
|
+
const first = columns[0];
|
|
145
|
+
const last = columns.at(-1);
|
|
146
|
+
if (!first || !last)
|
|
147
|
+
return "";
|
|
148
|
+
const month = new Intl.DateTimeFormat("en-IN", { timeZone: zone, month: "long", year: "numeric" });
|
|
149
|
+
return first.monthLabel === last.monthLabel
|
|
150
|
+
? month.format(first.startsAt)
|
|
151
|
+
: `${first.monthLabel} - ${month.format(last.startsAt)}`;
|
|
152
|
+
}
|
|
153
|
+
function timeLabel(instant, zone) {
|
|
154
|
+
return new Intl.DateTimeFormat("en-IN", {
|
|
155
|
+
timeZone: zone,
|
|
156
|
+
hour: "numeric",
|
|
157
|
+
minute: "2-digit",
|
|
158
|
+
hour12: true,
|
|
159
|
+
}).format(instant);
|
|
160
|
+
}
|
|
161
|
+
/** The next whole hour - where "New appointment" opens with no slot picked. */
|
|
162
|
+
function nextHour(now) {
|
|
163
|
+
const next = new Date(now);
|
|
164
|
+
next.setMinutes(0, 0, 0);
|
|
165
|
+
return new Date(next.getTime() + 60 * 60_000);
|
|
166
|
+
}
|
|
167
|
+
export { timeLabel as connectBookingTimeLabel };
|