@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.
Files changed (31) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +20 -1
  3. package/dist/cjs/client/components/booking-calendar.js +170 -0
  4. package/dist/cjs/client/components/booking-dialogs.js +124 -0
  5. package/dist/cjs/client/components/booking-grid.js +206 -0
  6. package/dist/cjs/client/index.js +15 -1
  7. package/dist/cjs/client/use-scheduling.js +113 -0
  8. package/dist/cjs/server/client.js +5 -1
  9. package/dist/cjs/server/handler.js +136 -0
  10. package/dist/cjs/server/resources/contacts.js +66 -0
  11. package/dist/cjs/server/resources/scheduling.js +32 -1
  12. package/dist/esm/client/components/booking-calendar.js +167 -0
  13. package/dist/esm/client/components/booking-dialogs.js +120 -0
  14. package/dist/esm/client/components/booking-grid.js +199 -0
  15. package/dist/esm/client/index.js +4 -0
  16. package/dist/esm/client/use-scheduling.js +107 -0
  17. package/dist/esm/server/client.js +5 -1
  18. package/dist/esm/server/handler.js +136 -0
  19. package/dist/esm/server/resources/contacts.js +62 -0
  20. package/dist/esm/server/resources/scheduling.js +32 -1
  21. package/dist/types/client/components/booking-calendar.d.ts +38 -0
  22. package/dist/types/client/components/booking-dialogs.d.ts +33 -0
  23. package/dist/types/client/components/booking-grid.d.ts +102 -0
  24. package/dist/types/client/index.d.ts +5 -0
  25. package/dist/types/client/use-scheduling.d.ts +93 -0
  26. package/dist/types/server/client.d.ts +4 -1
  27. package/dist/types/server/index.d.ts +2 -1
  28. package/dist/types/server/resources/contacts.d.ts +60 -0
  29. package/dist/types/server/resources/scheduling.d.ts +51 -6
  30. package/dist/types/types.d.ts +58 -0
  31. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,130 @@
3
3
  `@cosmoops/connect-sdk` follows semver, with the 0.x convention that **minor
4
4
  versions may carry breaking changes** and patch versions never do.
5
5
 
6
+ ## 0.19.0
7
+
8
+ ### Added
9
+
10
+ - **`connect.contacts`** - who a workspace already knows how to reach.
11
+
12
+ ```ts
13
+ const contact = await connect.contacts.lookupByPhone({ workspaceId, phone });
14
+ if (contact) {
15
+ // A caller this workspace has met before.
16
+ }
17
+ ```
18
+
19
+ `lookupByPhone` is one point read, cheap enough for a screen pop on every
20
+ inbound call, and resolves to `null` for a first-time caller - the ordinary
21
+ case, not an error. `get(contactId, { workspaceId })` reads the record behind
22
+ an id you already hold. Needs a key scoped to `contacts:read` and a live
23
+ subscription in the workspace.
24
+
25
+ **This is the telephony identity, not a CRM record.** A contact is keyed by
26
+ `primaryPhone` - the one fact every inbound call carries - and it is written
27
+ automatically: a voice agent creates one on the first call from a new number
28
+ and fills fields in as it learns them. `source` tells you which kind you are
29
+ holding (`call` means an agent guessed and nobody has confirmed), and
30
+ `phones[].verified` tells you the same about a single value.
31
+
32
+ **Reference it, do not copy it.** If your application keeps its own customer
33
+ records, store this contact's `id` on yours. Two writable copies of a
34
+ person's name with no single owner drift, and Connect's is the one an agent
35
+ keeps updating from calls. Storing the id also makes `Booking.contactId` an
36
+ **exact** join - so "which of my records is this appointment for" is a lookup
37
+ rather than a phone-number match, including for appointments a voice agent
38
+ booked where nobody ever chose one of your records.
39
+
40
+ Read-only, deliberately. Writing into a store with a per-field provenance
41
+ chain means declaring where your facts came from and how they merge with a
42
+ human's; nothing needs that yet.
43
+
44
+ - New exported types: `ConnectContact`, `ContactChannelValue`, `ContactSource`,
45
+ and `ContactsResource` / `LookupContactInput` from
46
+ `@cosmoops/connect-sdk/server`.
47
+
48
+ ## 0.18.0
49
+
50
+ ### Added
51
+
52
+ - **`connect.scheduling.rescheduleBooking()`** - move an appointment without
53
+ cancelling and rebooking it.
54
+
55
+ ```ts
56
+ const { booking, previous } = await connect.scheduling.rescheduleBooking({
57
+ workspaceId,
58
+ bookingId,
59
+ startAt: newSlot.startAt,
60
+ });
61
+ ```
62
+
63
+ The original is **retired, not edited**: it stays on the workspace's diary as
64
+ `rescheduled` with `rescheduledToId` pointing at its replacement, and both
65
+ come back. So a booking id you cached still resolves and still says where the
66
+ appointment went, and a report that already counted the original hour is not
67
+ rewritten under it.
68
+
69
+ **A refused move changes nothing.** The new slot is claimed before the old one
70
+ is released, so a `conflict` leaves the customer holding the appointment they
71
+ already had.
72
+
73
+ - **`excludeBookingId` on `findSlots()`** - set it when offering times to move
74
+ an existing appointment to. Without it the appointment counts as blocking its
75
+ own replacement, and the adjustment people actually make - same day, half an
76
+ hour later - comes back with nothing available.
77
+
78
+ - **`Booking.rescheduledToId`**, set when `status` is `rescheduled`.
79
+
80
+ - **`<ConnectBookingCalendar>`** - the workspace's week, in your app.
81
+
82
+ ```tsx
83
+ import { ConnectBookingCalendar } from "@cosmoops/connect-sdk/client";
84
+
85
+ <ConnectBookingCalendar timeZone="Asia/Kolkata" />
86
+ ```
87
+
88
+ A week grid with booking, rescheduling and cancelling built in. Clicking an
89
+ empty hour opens a slot picker; clicking an appointment opens it with Move
90
+ and Cancel. Everything goes through the bridge as the signed-in member, so
91
+ Connect checks *their* `calendar:read` / `calendar:manage` - pass
92
+ `canBook={false}` to render the read-only shape without offering a button
93
+ that would be refused.
94
+
95
+ **Two views, and the viewport decides.** A seven-column time grid needs about
96
+ 640px before a column can hold a name, so below `sm:` the same week renders
97
+ as a grouped agenda - a different shape for a phone, not a squeezed grid.
98
+
99
+ **`timeZone` is the business's, not the viewer's.** Every instant is read
100
+ against it, so a workspace's working day does not shift because a member is
101
+ travelling. It defaults to the host's zone, which is right when staff and
102
+ business share one; pass it when they might not.
103
+
104
+ - **Hooks, if you would rather build your own surface**:
105
+ `useBookableEventTypes`, `useAvailableSlots`, `useBookings`,
106
+ `useBookingActions`. None of the actions is optimistic - whether a slot is
107
+ still free is a question only the server can answer inside the transaction
108
+ that decides it.
109
+
110
+ - **`layoutBookings` and friends** from `booking-grid.js`, exported so a custom
111
+ grid can reuse the arithmetic: zone-correct day bucketing (half-hour zones
112
+ and DST included), transitive overlap clustering into side-by-side columns,
113
+ and a minimum height so a fifteen-minute appointment stays clickable.
114
+
115
+ - **`asUser` on every `scheduling.*` method**, and the six scheduling routes it
116
+ exists for on `createConnectHandler`'s bridge:
117
+ `GET /scheduling/{event-types,slots,bookings}` and
118
+ `POST /scheduling/bookings{,/reschedule,/cancel}`.
119
+
120
+ With `asUser`, Connect derives the workspace from **that member's session**
121
+ and ignores the `workspaceId` you passed, then checks their own
122
+ `calendar:read` / `calendar:manage`. The bridge always sends it, and that is
123
+ what makes a browser-facing booking component safe: a browser must never name
124
+ a tenant, and an end user of your app is not automatically allowed to book on
125
+ their employer's calendar just because your API key can.
126
+
127
+ Without `asUser` nothing changes - the call is your server acting for a
128
+ workspace you name, gated on your application being installed there.
129
+
6
130
  ## 0.17.0
7
131
 
8
132
  ### Added
package/README.md CHANGED
@@ -342,7 +342,8 @@ try {
342
342
  | `entitlements` | `getReadiness` · `getSubscription` · `hasFeature` |
343
343
  | `billing` | `listPlans` · `getAddonUsage` · `consumeAddon` |
344
344
  | `drive` | `upload` · `getFileUrl` · `getPreviewUrl` |
345
- | `scheduling` | `listEventTypes` · `findSlots` · `listBookings` · `createBooking` · `cancelBooking` |
345
+ | `contacts` | `lookupByPhone` · `get` |
346
+ | `scheduling` | `listEventTypes` · `findSlots` · `listBookings` · `createBooking` · `rescheduleBooking` · `cancelBooking` |
346
347
  | `ai` | `chat` · `extract` |
347
348
  | `otp` | `send` · `verify` |
348
349
  | `logs` | `write` |
@@ -350,6 +351,24 @@ try {
350
351
  | `sessions` | `list` · `revoke` · `revokeOthers` |
351
352
  | `integrations` | `list` · `initiateConnect` · `disconnect` · `execute` |
352
353
 
354
+ ### The calendar, as a component
355
+
356
+ ```tsx
357
+ import { ConnectBookingCalendar } from "@cosmoops/connect-sdk/client";
358
+
359
+ <ConnectBookingCalendar timeZone="Asia/Kolkata" />
360
+ ```
361
+
362
+ A week grid on a desktop, a grouped agenda on a phone, with booking,
363
+ rescheduling and cancelling built in. It reads through the bridge as the
364
+ signed-in member, so Connect checks their own `calendar:read` /
365
+ `calendar:manage` - not your API key. `canBook={false}` renders the read-only
366
+ shape.
367
+
368
+ `timeZone` is the **business's** zone. Every instant on the grid is read
369
+ against it, so a workspace's working day does not shift because a member is
370
+ travelling.
371
+
353
372
  ### Booking on a workspace's calendar
354
373
 
355
374
  One calendar per workspace - the same one their team configures, and the same
@@ -0,0 +1,170 @@
1
+ "use client";
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.ConnectBookingCalendar = ConnectBookingCalendar;
5
+ exports.connectBookingTimeLabel = timeLabel;
6
+ const jsx_runtime_1 = require("react/jsx-runtime");
7
+ const react_1 = require("@heroui/react");
8
+ const lucide_react_1 = require("lucide-react");
9
+ const react_2 = require("react");
10
+ const empty_state_js_1 = require("../../ui/empty-state.js");
11
+ const use_scheduling_js_1 = require("../use-scheduling.js");
12
+ const booking_grid_js_1 = require("./booking-grid.js");
13
+ const booking_dialogs_js_1 = require("./booking-dialogs.js");
14
+ /**
15
+ * The visible band of the day. Outside it a booking is drawn at the edge and
16
+ * flagged rather than hidden - see `layoutBookings`.
17
+ *
18
+ * 07:00-21:00 rather than a full day: fourteen hours is the most that stays
19
+ * legible on a laptop without scrolling, and a booking grid whose first screen
20
+ * is six empty night-time hours reads as a broken calendar.
21
+ */
22
+ const DEFAULT_WINDOW = { startMinute: 7 * 60, endMinute: 21 * 60 };
23
+ const LOCATION_ICON = {
24
+ phone: lucide_react_1.Phone,
25
+ address: lucide_react_1.CalendarDays,
26
+ link: lucide_react_1.Video,
27
+ };
28
+ /**
29
+ * A week of the workspace's calendar, with booking, rescheduling and
30
+ * cancelling.
31
+ *
32
+ * **Two views, and which one you get is a viewport decision, not a
33
+ * preference.** A seven-column time grid needs roughly 640px before a column
34
+ * is wide enough to hold a name; below that it degrades into unreadable
35
+ * slivers. So the grid is `sm:` and up, and the same week renders as a grouped
36
+ * agenda underneath - not a cut-down grid, a different and better shape for a
37
+ * phone. Both read the same data and open the same dialogs.
38
+ *
39
+ * Everything it does goes through the bridge as the signed-in member, so
40
+ * Connect checks *their* `calendar:read` / `calendar:manage` rather than the
41
+ * host application's key. A member who may look but not book gets the grid and
42
+ * a refusal on the write, which is the correct shape - see `useBookingActions`.
43
+ */
44
+ function ConnectBookingCalendar({ timeZone, hostMemberId, window: visibleWindow = DEFAULT_WINDOW, canBook = true, className, }) {
45
+ const zone = timeZone ?? hostTimeZone();
46
+ const [weekAnchor, setWeekAnchor] = (0, react_2.useState)(() => startOfWeek(new Date(), zone));
47
+ const [selected, setSelected] = (0, react_2.useState)(null);
48
+ const [booking, setBooking] = (0, react_2.useState)(null);
49
+ const now = new Date();
50
+ const columns = (0, react_2.useMemo)(() => (0, booking_grid_js_1.dayColumns)(weekAnchor, zone, 7, now),
51
+ // `now` is deliberately not a dependency: it changes on every render, and
52
+ // the only thing it decides is which column is highlighted. Recomputing
53
+ // seven columns per keystroke elsewhere on the page to move a highlight
54
+ // that changes once a day is not a trade worth making.
55
+ // eslint-disable-next-line react-hooks/exhaustive-deps
56
+ [weekAnchor, zone]);
57
+ const range = (0, react_2.useMemo)(() => ({
58
+ from: columns[0]?.startsAt ?? weekAnchor,
59
+ to: new Date((columns.at(-1)?.startsAt.getTime() ?? weekAnchor.getTime()) + 24 * 60 * 60_000),
60
+ }), [columns, weekAnchor]);
61
+ const { eventTypes } = (0, use_scheduling_js_1.useBookableEventTypes)();
62
+ const { bookings, isLoading, error, refetch } = (0, use_scheduling_js_1.useBookings)({
63
+ from: range.from,
64
+ to: range.to,
65
+ hostMemberId,
66
+ });
67
+ const positioned = (0, react_2.useMemo)(() => (0, booking_grid_js_1.layoutBookings)(bookings, zone, visibleWindow), [bookings, zone, visibleWindow]);
68
+ const marks = (0, react_2.useMemo)(() => (0, booking_grid_js_1.hourMarks)(visibleWindow), [visibleWindow]);
69
+ const shiftWeek = (weeks) => setWeekAnchor((anchor) => new Date(anchor.getTime() + weeks * 7 * 24 * 60 * 60_000));
70
+ const eventTypeById = (0, react_2.useMemo)(() => new Map(eventTypes.map((eventType) => [eventType.id, eventType])), [eventTypes]);
71
+ return ((0, jsx_runtime_1.jsxs)("div", { className: `flex flex-col gap-4 ${className ?? ""}`, children: [(0, jsx_runtime_1.jsxs)("header", { className: "flex flex-wrap items-center justify-between gap-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1", children: [(0, jsx_runtime_1.jsx)(react_1.Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Previous week", onPress: () => shiftWeek(-1), children: (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronLeft, { className: "size-4", "aria-hidden": true }) }), (0, jsx_runtime_1.jsx)(react_1.Button, { size: "sm", variant: "secondary", onPress: () => setWeekAnchor(startOfWeek(new Date(), zone)), children: "Today" }), (0, jsx_runtime_1.jsx)(react_1.Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Next week", onPress: () => shiftWeek(1), children: (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronRight, { className: "size-4", "aria-hidden": true }) }), (0, jsx_runtime_1.jsx)("p", { className: "ml-2 text-sm font-medium", children: weekLabel(columns, zone) })] }), canBook && eventTypes.length > 0 && ((0, jsx_runtime_1.jsx)(react_1.Button, { size: "sm", variant: "primary", onPress: () => setBooking({ startAt: nextHour(now) }), children: "New appointment" }))] }), error && (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-danger", children: error }), isLoading ? ((0, jsx_runtime_1.jsx)(react_1.Skeleton, { className: "h-96 w-full rounded-lg" })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_1.Card, { className: "hidden overflow-hidden sm:block", children: (0, jsx_runtime_1.jsx)(WeekGrid, { columns: columns, marks: marks, window: visibleWindow, positioned: positioned, eventTypeById: eventTypeById, zone: zone, onSelect: setSelected, onPickEmpty: canBook ? (startAt) => setBooking({ startAt }) : undefined }) }), (0, jsx_runtime_1.jsx)("div", { className: "sm:hidden", children: (0, jsx_runtime_1.jsx)(AgendaView, { columns: columns, bookings: bookings, eventTypeById: eventTypeById, zone: zone, onSelect: setSelected }) })] })), booking && ((0, jsx_runtime_1.jsx)(booking_dialogs_js_1.ConnectBookingDialog, { isOpen: true, onOpenChange: (open) => !open && setBooking(null), eventTypes: eventTypes, initialStartAt: booking.startAt, timeZone: zone, onBooked: () => {
72
+ setBooking(null);
73
+ refetch();
74
+ } })), selected && ((0, jsx_runtime_1.jsx)(booking_dialogs_js_1.ConnectBookingDetail, { booking: selected, eventType: eventTypeById.get(selected.eventTypeId) ?? null, timeZone: zone, onOpenChange: (open) => !open && setSelected(null), onChanged: () => {
75
+ setSelected(null);
76
+ refetch();
77
+ } }))] }));
78
+ }
79
+ function WeekGrid({ columns, marks, window: visibleWindow, positioned, eventTypeById, zone, onSelect, onPickEmpty, }) {
80
+ const span = visibleWindow.endMinute - visibleWindow.startMinute;
81
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "grid grid-cols-[3rem_repeat(7,minmax(0,1fr))]", children: [(0, jsx_runtime_1.jsx)("div", { className: "border-b border-separator" }), columns.map((column) => ((0, jsx_runtime_1.jsx)("div", { className: `border-b border-l border-separator px-2 py-2 text-center ${column.isToday ? "bg-accent/5" : ""}`, children: (0, jsx_runtime_1.jsx)("p", { className: `text-xs font-medium ${column.isToday ? "text-accent" : ""}`, children: column.label }) }, column.dateKey))), (0, jsx_runtime_1.jsx)("div", { className: "relative", style: { height: `${(span / 60) * 3}rem` }, children: marks.map((mark) => ((0, jsx_runtime_1.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) => ((0, jsx_runtime_1.jsxs)("div", { className: `relative border-l border-separator ${column.isToday ? "bg-accent/5" : ""}`, style: { height: `${(span / 60) * 3}rem` }, children: [marks.map((mark) => ((0, jsx_runtime_1.jsx)("div", { className: "absolute inset-x-0 border-t border-separator/60", style: { top: `${((mark.minute - visibleWindow.startMinute) / span) * 100}%` } }, mark.minute))), onPickEmpty &&
82
+ marks.map((mark) => ((0, jsx_runtime_1.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: {
83
+ top: `${((mark.minute - visibleWindow.startMinute) / span) * 100}%`,
84
+ height: `${(60 / span) * 100}%`,
85
+ }, onClick: () => onPickEmpty(new Date(column.startsAt.getTime() + mark.minute * 60_000)) }, `slot-${mark.minute}`))), positioned
86
+ .filter((entry) => entry.dateKey === column.dateKey)
87
+ .map((entry) => {
88
+ const eventType = eventTypeById.get(entry.booking.eventTypeId);
89
+ return ((0, jsx_runtime_1.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: {
90
+ top: `${entry.top * 100}%`,
91
+ height: `${entry.height * 100}%`,
92
+ // Side-by-side within an overlap cluster, with a hairline
93
+ // gap so two adjacent blocks read as two things.
94
+ left: `${(entry.column / entry.columns) * 100}%`,
95
+ width: `calc(${(1 / entry.columns) * 100}% - 2px)`,
96
+ }, children: [(0, jsx_runtime_1.jsx)("span", { className: "block truncate text-[0.625rem] font-medium text-accent", children: timeLabel(new Date(entry.booking.startAt), zone) }), (0, jsx_runtime_1.jsx)("span", { className: "block truncate text-[0.625rem]", children: entry.booking.attendee.name }), eventType && ((0, jsx_runtime_1.jsx)("span", { className: "block truncate text-[0.625rem] text-muted", children: eventType.name }))] }, entry.booking.id));
97
+ })] }, column.dateKey)))] }));
98
+ }
99
+ function AgendaView({ columns, bookings, eventTypeById, zone, onSelect, }) {
100
+ const byDay = (0, react_2.useMemo)(() => {
101
+ const map = new Map();
102
+ for (const booking of bookings) {
103
+ const { dateKey } = (0, booking_grid_js_1.zonedInstant)(new Date(booking.startAt), zone);
104
+ map.set(dateKey, [...(map.get(dateKey) ?? []), booking]);
105
+ }
106
+ return map;
107
+ }, [bookings, zone]);
108
+ // Days with nothing on them are omitted rather than rendered empty: an
109
+ // agenda is a list of what is happening, and seven "nothing" rows is six
110
+ // screens of scrolling to find the one day that matters.
111
+ const populated = columns.filter((column) => (byDay.get(column.dateKey)?.length ?? 0) > 0);
112
+ if (populated.length === 0) {
113
+ return ((0, jsx_runtime_1.jsx)(empty_state_js_1.ConnectEmptyState, { variant: "dashed", icon: (0, jsx_runtime_1.jsx)(lucide_react_1.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." }));
114
+ }
115
+ return ((0, jsx_runtime_1.jsx)("div", { className: "flex flex-col gap-4", children: populated.map((column) => ((0, jsx_runtime_1.jsxs)("section", { className: "flex flex-col gap-2", children: [(0, jsx_runtime_1.jsx)("h3", { className: `text-sm font-medium ${column.isToday ? "text-accent" : ""}`, children: column.label }), (byDay.get(column.dateKey) ?? []).map((booking) => {
116
+ const eventType = eventTypeById.get(booking.eventTypeId);
117
+ const Icon = eventType ? LOCATION_ICON[eventType.locationKind] : lucide_react_1.CalendarDays;
118
+ return ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => onSelect(booking), children: (0, jsx_runtime_1.jsx)(react_1.Card, { className: "transition-colors hover:bg-surface", children: (0, jsx_runtime_1.jsxs)(react_1.Card.Content, { className: "flex items-center gap-3 py-3 text-left", children: [(0, jsx_runtime_1.jsx)(Icon, { className: "size-4 shrink-0 text-muted", "aria-hidden": true }), (0, jsx_runtime_1.jsxs)("div", { className: "min-w-0 flex-1", children: [(0, jsx_runtime_1.jsx)("p", { className: "truncate text-sm font-medium", children: booking.attendee.name }), (0, jsx_runtime_1.jsxs)("p", { className: "truncate text-xs text-muted", children: [timeLabel(new Date(booking.startAt), zone), eventType ? ` · ${eventType.name}` : ""] })] }), booking.source === "agent-call" && ((0, jsx_runtime_1.jsxs)(react_1.Chip, { size: "sm", variant: "secondary", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Phone, { className: "size-3", "aria-hidden": true }), "Agent"] }))] }) }) }, booking.id));
119
+ })] }, column.dateKey))) }));
120
+ }
121
+ /* ------------------------------------------------------------------ helpers */
122
+ /** The host's zone, used when the caller does not name the business's. */
123
+ function hostTimeZone() {
124
+ try {
125
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
126
+ }
127
+ catch {
128
+ return "UTC";
129
+ }
130
+ }
131
+ /**
132
+ * Monday of the week `instant` falls in, in `zone`.
133
+ *
134
+ * Monday rather than Sunday: this is a *business* calendar, and a week that
135
+ * splits the working days across two screens is the one thing a week view must
136
+ * not do.
137
+ */
138
+ function startOfWeek(instant, zone) {
139
+ const { dateKey } = (0, booking_grid_js_1.zonedInstant)(instant, zone);
140
+ const midnight = (0, booking_grid_js_1.dayStartInstant)(dateKey, zone);
141
+ // `getUTCDay` on local midnight-as-an-instant is not the local weekday, so
142
+ // the weekday is read back through the zone.
143
+ const weekday = new Intl.DateTimeFormat("en-US", { timeZone: zone, weekday: "short" }).format(midnight);
144
+ const offset = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].indexOf(weekday);
145
+ return new Date(midnight.getTime() - (offset < 0 ? 0 : offset) * 24 * 60 * 60_000);
146
+ }
147
+ function weekLabel(columns, zone) {
148
+ const first = columns[0];
149
+ const last = columns.at(-1);
150
+ if (!first || !last)
151
+ return "";
152
+ const month = new Intl.DateTimeFormat("en-IN", { timeZone: zone, month: "long", year: "numeric" });
153
+ return first.monthLabel === last.monthLabel
154
+ ? month.format(first.startsAt)
155
+ : `${first.monthLabel} - ${month.format(last.startsAt)}`;
156
+ }
157
+ function timeLabel(instant, zone) {
158
+ return new Intl.DateTimeFormat("en-IN", {
159
+ timeZone: zone,
160
+ hour: "numeric",
161
+ minute: "2-digit",
162
+ hour12: true,
163
+ }).format(instant);
164
+ }
165
+ /** The next whole hour - where "New appointment" opens with no slot picked. */
166
+ function nextHour(now) {
167
+ const next = new Date(now);
168
+ next.setMinutes(0, 0, 0);
169
+ return new Date(next.getTime() + 60 * 60_000);
170
+ }
@@ -0,0 +1,124 @@
1
+ "use client";
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.ConnectBookingDialog = ConnectBookingDialog;
5
+ exports.ConnectBookingDetail = ConnectBookingDetail;
6
+ const jsx_runtime_1 = require("react/jsx-runtime");
7
+ const react_1 = require("@heroui/react");
8
+ const lucide_react_1 = require("lucide-react");
9
+ const react_2 = require("react");
10
+ const use_scheduling_js_1 = require("../use-scheduling.js");
11
+ const SEARCH_DAYS = 14;
12
+ /**
13
+ * Picking a time, and then who it is for.
14
+ *
15
+ * The slot list is read fresh every time this opens and is **never cached
16
+ * across an open**: it is a snapshot of contention, and offering a time
17
+ * somebody took ninety seconds ago is how two people end up in the same slot.
18
+ * The server re-checks inside its transaction regardless, so a stale list
19
+ * costs a refusal rather than a double booking - this only makes the refusal
20
+ * rare enough to be surprising when it happens.
21
+ */
22
+ function ConnectBookingDialog({ isOpen, onOpenChange, eventTypes, initialStartAt, timeZone, onBooked, }) {
23
+ const [eventTypeId, setEventTypeId] = (0, react_2.useState)(eventTypes[0]?.id ?? "");
24
+ const [chosen, setChosen] = (0, react_2.useState)(null);
25
+ const [name, setName] = (0, react_2.useState)("");
26
+ const [phone, setPhone] = (0, react_2.useState)("");
27
+ const window = (0, react_2.useMemo)(() => ({
28
+ // From the start of the day that was clicked, so the times on offer are
29
+ // the ones near where the person was looking.
30
+ from: new Date(initialStartAt.getTime() - 12 * 60 * 60_000),
31
+ to: new Date(initialStartAt.getTime() + SEARCH_DAYS * 24 * 60 * 60_000),
32
+ }), [initialStartAt]);
33
+ const { slots, isLoading } = (0, use_scheduling_js_1.useAvailableSlots)(eventTypeId ? { eventTypeId, from: window.from, to: window.to } : null);
34
+ const { createBooking, isPending, error, clearError } = (0, use_scheduling_js_1.useBookingActions)();
35
+ const canSubmit = Boolean(chosen && name.trim() && phone.trim()) && !isPending;
36
+ return ((0, jsx_runtime_1.jsx)(react_1.Modal, { isOpen: isOpen, onOpenChange: onOpenChange, children: (0, jsx_runtime_1.jsx)(react_1.Modal.Backdrop, { children: (0, jsx_runtime_1.jsx)(react_1.Modal.Container, { children: (0, jsx_runtime_1.jsxs)(react_1.Modal.Dialog, { children: [(0, jsx_runtime_1.jsx)(react_1.Modal.Header, { children: "New appointment" }), (0, jsx_runtime_1.jsxs)(react_1.Modal.Body, { className: "flex flex-col gap-4", children: [eventTypes.length > 1 && ((0, jsx_runtime_1.jsx)("div", { className: "flex flex-wrap gap-2", children: eventTypes.map((eventType) => ((0, jsx_runtime_1.jsx)(react_1.Button, { size: "sm", variant: eventType.id === eventTypeId ? "primary" : "secondary", onPress: () => {
37
+ setEventTypeId(eventType.id);
38
+ // The old pick belongs to the old type's grid and is
39
+ // almost never a legal slot on the new one.
40
+ setChosen(null);
41
+ }, children: eventType.name }, eventType.id))) })), (0, jsx_runtime_1.jsx)(SlotPicker, { slots: slots, isLoading: isLoading, chosen: chosen, onChoose: setChosen, timeZone: timeZone }), (0, jsx_runtime_1.jsxs)(react_1.TextField, { isRequired: true, variant: "secondary", children: [(0, jsx_runtime_1.jsx)(react_1.Label, { children: "Name" }), (0, jsx_runtime_1.jsx)(react_1.Input, { value: name, onChange: (e) => setName(e.currentTarget.value), disabled: isPending })] }), (0, jsx_runtime_1.jsxs)(react_1.TextField, { isRequired: true, variant: "secondary", children: [(0, jsx_runtime_1.jsx)(react_1.Label, { children: "Phone" }), (0, jsx_runtime_1.jsx)(react_1.Input, { type: "tel", value: phone, onChange: (e) => setPhone(e.currentTarget.value), disabled: isPending })] }), error && (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-danger", children: error }), (0, jsx_runtime_1.jsxs)("div", { className: "flex justify-end gap-2", children: [(0, jsx_runtime_1.jsx)(react_1.Button, { variant: "secondary", onPress: () => onOpenChange(false), isDisabled: isPending, children: "Cancel" }), (0, jsx_runtime_1.jsx)(react_1.Button, { variant: "primary", isDisabled: !canSubmit, onPress: async () => {
42
+ clearError();
43
+ const booked = await createBooking({
44
+ eventTypeId,
45
+ startAt: chosen,
46
+ attendee: { name: name.trim(), phone: phone.trim(), email: null },
47
+ });
48
+ if (booked)
49
+ onBooked(booked);
50
+ }, children: isPending ? "Booking…" : "Book" })] })] })] }) }) }) }));
51
+ }
52
+ /**
53
+ * One appointment, and the two things anyone opens it to do.
54
+ *
55
+ * Rescheduling re-reads the slots with `excludeBookingId` set to this booking.
56
+ * Without that the appointment blocks its own replacement, and moving it by
57
+ * half an hour - the only move most people ever make - offers nothing.
58
+ */
59
+ function ConnectBookingDetail({ booking, eventType, timeZone, onOpenChange, onChanged, }) {
60
+ const [isMoving, setMoving] = (0, react_2.useState)(false);
61
+ const [chosen, setChosen] = (0, react_2.useState)(null);
62
+ const { rescheduleBooking, cancelBooking, isPending, error, clearError } = (0, use_scheduling_js_1.useBookingActions)();
63
+ const start = new Date(booking.startAt);
64
+ const window = (0, react_2.useMemo)(() => ({
65
+ from: new Date(start.getTime() - 12 * 60 * 60_000),
66
+ to: new Date(start.getTime() + SEARCH_DAYS * 24 * 60 * 60_000),
67
+ }), [start]);
68
+ const { slots, isLoading } = (0, use_scheduling_js_1.useAvailableSlots)(isMoving ? { eventTypeId: booking.eventTypeId, ...window, excludeBookingId: booking.id } : null);
69
+ return ((0, jsx_runtime_1.jsx)(react_1.Modal, { isOpen: true, onOpenChange: onOpenChange, children: (0, jsx_runtime_1.jsx)(react_1.Modal.Backdrop, { children: (0, jsx_runtime_1.jsx)(react_1.Modal.Container, { children: (0, jsx_runtime_1.jsxs)(react_1.Modal.Dialog, { children: [(0, jsx_runtime_1.jsx)(react_1.Modal.Header, { children: booking.attendee.name }), (0, jsx_runtime_1.jsxs)(react_1.Modal.Body, { className: "flex flex-col gap-4", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1", children: [(0, jsx_runtime_1.jsxs)("p", { className: "flex items-center gap-2 text-sm", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Clock, { className: "size-4 text-muted", "aria-hidden": true }), longLabel(start, timeZone)] }), eventType && (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: eventType.name }), (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: booking.attendee.phone }), booking.notes && (0, jsx_runtime_1.jsx)("p", { className: "pt-1 text-sm", children: booking.notes })] }), isMoving && ((0, jsx_runtime_1.jsx)(SlotPicker, { slots: slots, isLoading: isLoading, chosen: chosen, onChoose: setChosen, timeZone: timeZone })), error && (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-danger", children: error }), (0, jsx_runtime_1.jsx)("div", { className: "flex flex-wrap justify-end gap-2", children: isMoving ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_1.Button, { variant: "secondary", onPress: () => setMoving(false), isDisabled: isPending, children: "Back" }), (0, jsx_runtime_1.jsx)(react_1.Button, { variant: "primary", isDisabled: !chosen || isPending, onPress: async () => {
70
+ clearError();
71
+ const moved = await rescheduleBooking({
72
+ bookingId: booking.id,
73
+ startAt: chosen,
74
+ });
75
+ if (moved)
76
+ onChanged();
77
+ }, children: isPending ? "Moving…" : "Move appointment" })] })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_1.Button, { variant: "danger", isDisabled: isPending, onPress: async () => {
78
+ clearError();
79
+ const cancelled = await cancelBooking({ bookingId: booking.id });
80
+ if (cancelled)
81
+ onChanged();
82
+ }, children: isPending ? "Cancelling…" : "Cancel appointment" }), (0, jsx_runtime_1.jsx)(react_1.Button, { variant: "secondary", onPress: () => setMoving(true), children: "Reschedule" })] })) })] })] }) }) }) }));
83
+ }
84
+ function SlotPicker({ slots, isLoading, chosen, onChoose, timeZone, }) {
85
+ const byDay = (0, react_2.useMemo)(() => {
86
+ const groups = new Map();
87
+ const dayFormat = new Intl.DateTimeFormat("en-IN", {
88
+ timeZone,
89
+ weekday: "short",
90
+ day: "numeric",
91
+ month: "short",
92
+ });
93
+ for (const slot of slots) {
94
+ const label = dayFormat.format(new Date(slot.startAt));
95
+ groups.set(label, [...(groups.get(label) ?? []), slot]);
96
+ }
97
+ return [...groups.entries()];
98
+ }, [slots, timeZone]);
99
+ if (isLoading)
100
+ return (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: "Finding free times\u2026" });
101
+ if (byDay.length === 0) {
102
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2 rounded-lg border border-dashed border-separator p-4", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.CalendarX2, { className: "size-4 text-muted", "aria-hidden": true }), (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: "Nothing free in the next two weeks." })] }));
103
+ }
104
+ return ((0, jsx_runtime_1.jsx)("div", { className: "flex max-h-56 flex-col gap-3 overflow-y-auto", children: byDay.map(([day, daySlots]) => ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1.5", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-xs font-medium text-muted", children: day }), (0, jsx_runtime_1.jsx)("div", { className: "grid grid-cols-2 gap-1.5 sm:grid-cols-4", children: daySlots.map((slot) => ((0, jsx_runtime_1.jsx)(react_1.Button, { size: "sm", className: "w-full", variant: slot.startAt === chosen ? "primary" : "secondary", onPress: () => onChoose(slot.startAt), children: shortTime(new Date(slot.startAt), timeZone) }, slot.startAt))) })] }, day))) }));
105
+ }
106
+ function shortTime(instant, timeZone) {
107
+ return new Intl.DateTimeFormat("en-IN", {
108
+ timeZone,
109
+ hour: "numeric",
110
+ minute: "2-digit",
111
+ hour12: true,
112
+ }).format(instant);
113
+ }
114
+ function longLabel(instant, timeZone) {
115
+ return new Intl.DateTimeFormat("en-IN", {
116
+ timeZone,
117
+ weekday: "long",
118
+ day: "numeric",
119
+ month: "long",
120
+ hour: "numeric",
121
+ minute: "2-digit",
122
+ hour12: true,
123
+ }).format(instant);
124
+ }