@mrcharles/sdk 0.0.0-stage → 0.1.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/README.md +113 -2
- package/dist/mrcharles.esm.js +509 -0
- package/dist/mrcharles.esm.js.map +19 -0
- package/dist/mrcharles.min.js +4 -0
- package/dist/mrcharles.min.js.map +20 -0
- package/dist/types/client.d.ts +63 -0
- package/dist/types/embed.d.ts +26 -0
- package/dist/types/env.d.ts +30 -0
- package/dist/types/errors.d.ts +15 -0
- package/dist/types/http.d.ts +7 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/links.d.ts +33 -0
- package/dist/types/rooms.d.ts +64 -0
- package/dist/types/slots.d.ts +19 -0
- package/dist/types/types.d.ts +337 -0
- package/dist/types/uuid.d.ts +2 -0
- package/package.json +55 -4
package/README.md
CHANGED
|
@@ -1,3 +1,114 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mrcharles/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Put Mr Charles booking on any website: studio rooms (guests, no account) and
|
|
4
|
+
teacher lessons (hand-off to the app). Zero dependencies, ~6 kB min+gz, works
|
|
5
|
+
in the browser, in Astro/Next/SvelteKit server code, and as a plain `<script>`.
|
|
6
|
+
|
|
7
|
+
Docs: https://developers.mrcharles.app/integrate/sdk/
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm i @mrcharles/sdk
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
or without a bundler:
|
|
16
|
+
|
|
17
|
+
```html
|
|
18
|
+
<script src="https://developers.mrcharles.app/sdk/v0/mrcharles.min.js"></script>
|
|
19
|
+
<!-- exposes window.MrCharles -->
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## 30-second version: embed our booking screen
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
import { createClient } from '@mrcharles/sdk';
|
|
26
|
+
|
|
27
|
+
const mc = createClient();
|
|
28
|
+
mc.mountRoomBooking('#book', {
|
|
29
|
+
slug: 'studio-uno-madrid',
|
|
30
|
+
roomId: 'd9a36c09-d151-5d4a-a077-296293b07be4',
|
|
31
|
+
lang: 'es',
|
|
32
|
+
onBooked: ({ reference, manageUrl }) => console.log('booked', reference, manageUrl),
|
|
33
|
+
});
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The iframe resizes to its content, takes card deposits, sends the SMS code,
|
|
37
|
+
and shows the manage link. You write nothing else.
|
|
38
|
+
|
|
39
|
+
## Your own UI: the headless flow
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
const flow = mc.roomBooking({ slug, roomId, onChange: render });
|
|
43
|
+
|
|
44
|
+
await flow.loadSite(); // rooms, prices, timezone, captcha/stripe keys
|
|
45
|
+
await flow.loadAvailability({ days: 14 }); // free spans, studio's zone
|
|
46
|
+
flow.startTimesFor('2026-10-20', 120); // → ['09:00', '10:00', …]
|
|
47
|
+
flow.durationsFor('2026-10-20', '10:00'); // → [60, 120, 180]
|
|
48
|
+
|
|
49
|
+
await flow.hold({ date, startTime, durationMinutes, guestName, channel: 'phone', contact: '+34600111222' });
|
|
50
|
+
// state.step === 'verify' · flow.remainingMs() counts down from 12 min
|
|
51
|
+
await flow.verify(code); // 6 digits; flow.resend() after 60 s
|
|
52
|
+
await flow.confirm({ paymentMode: 'on_site', notes }); // state.step === 'done'
|
|
53
|
+
flow.state.confirmation.manageUrl; // hand this to the guest
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Every error is a `MrCharlesError` with a stable `code` (`SLOT_TAKEN`,
|
|
57
|
+
`HOLD_EXPIRED`, `CODE_INVALID`, …) and three helpers: `slotIsGone`,
|
|
58
|
+
`holdIsDead`, `isTransient`. The flow rotates the idempotency key when a hold
|
|
59
|
+
dies and keeps it across network retries, so you can simply call `hold()` again.
|
|
60
|
+
|
|
61
|
+
## Lessons with a teacher
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
const t = await mc.teachers.profile(teacherUserId);
|
|
65
|
+
const offers = await mc.teachers.offers(teacherUserId);
|
|
66
|
+
const slots = await mc.teachers.slots({ instructorUserId: t.user.id, offerId: offers[0].id, date: '2026-10-13', days: 7 });
|
|
67
|
+
// <a href={mc.links.bookLesson({ offerId: offers[0].id, instructorId: t.user.id })}>Book</a>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Students book inside the Mr Charles app (payment, reminders, cancellations),
|
|
71
|
+
so the SDK gives you the reads and the link, not a guest checkout.
|
|
72
|
+
|
|
73
|
+
## Astro
|
|
74
|
+
|
|
75
|
+
```astro
|
|
76
|
+
---
|
|
77
|
+
import { createClient } from '@mrcharles/sdk';
|
|
78
|
+
const mc = createClient();
|
|
79
|
+
const site = await mc.sites.get('studio-uno-madrid'); // at build / on request
|
|
80
|
+
---
|
|
81
|
+
<h1>{site.studio.name}</h1>
|
|
82
|
+
{site.rooms.filter((r) => r.bookable).map((r) => <a href={mc.links.bookRoom({ slug: site.studio.slug, roomId: r.id })}>{r.name}</a>)}
|
|
83
|
+
<div id="book"></div>
|
|
84
|
+
<script>
|
|
85
|
+
import { createClient } from '@mrcharles/sdk';
|
|
86
|
+
createClient().mountRoomBooking('#book', { slug: 'studio-uno-madrid', roomId: '…' });
|
|
87
|
+
</script>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Availability and holds must run in the browser (or forward the guest's IP in
|
|
91
|
+
`headers: { 'X-Forwarded-For': ip }` when you proxy them).
|
|
92
|
+
|
|
93
|
+
## API surface
|
|
94
|
+
|
|
95
|
+
| | |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `createClient({ fetch, locale, headers })` | one client |
|
|
98
|
+
| `sites.get / resolve / availability` | studio payload, custom-domain lookup, free spans |
|
|
99
|
+
| `holds.create / verify / resend / confirm` | the raw calls, if you manage state yourself |
|
|
100
|
+
| `bookings.get / cancel` | a guest's booking by manage token |
|
|
101
|
+
| `teachers.profile / offers / offer / windows / policy / slots` | lesson reads |
|
|
102
|
+
| `links.site / bookRoom / manage / bookLesson / teacherProfile` | URLs for every hand-off |
|
|
103
|
+
| `mountRoomBooking(target, opts)` | iframe embed with resize + booked events |
|
|
104
|
+
| `roomBooking(opts)` | the stateful flow above |
|
|
105
|
+
| `startTimes / durationOptions / formatPrice / dateKey / addDays` | helpers |
|
|
106
|
+
|
|
107
|
+
## Testing without bothering anyone
|
|
108
|
+
|
|
109
|
+
There is one API. To develop, point the SDK at a studio site in **sandbox
|
|
110
|
+
mode** — the platform's `demo-studio`, or your own site with *Modo pruebas*
|
|
111
|
+
switched on in the dashboard. A sandbox site returns the verification code
|
|
112
|
+
as `hold.devCode`, sends no SMS or email, offers `on_site` only, and purges
|
|
113
|
+
its bookings after seven days. `site.site.sandbox` tells you which mode you
|
|
114
|
+
are in; switch the slug to the real one at launch and nothing else changes.
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
// src/env.ts
|
|
2
|
+
var ENVIRONMENTS = {
|
|
3
|
+
production: {
|
|
4
|
+
apiBase: "https://api.mrcharles.app/api/v1",
|
|
5
|
+
appBase: "https://app.mrcharles.app",
|
|
6
|
+
sitesBase: "sites.mrcharles.app"
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
function resolveEndpoints(config = {}) {
|
|
10
|
+
const base = ENVIRONMENTS[config.env ?? "production"];
|
|
11
|
+
return {
|
|
12
|
+
apiBase: trimSlash(config.apiBase ?? base.apiBase),
|
|
13
|
+
appBase: trimSlash(config.appBase ?? base.appBase),
|
|
14
|
+
sitesBase: (config.sitesBase ?? base.sitesBase).replace(/^https?:\/\//, "").replace(/\/+$/, "")
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function trimSlash(s) {
|
|
18
|
+
return s.replace(/\/+$/, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// src/errors.ts
|
|
22
|
+
class MrCharlesError extends Error {
|
|
23
|
+
code;
|
|
24
|
+
status;
|
|
25
|
+
requestId;
|
|
26
|
+
constructor(code, message, status = 0, requestId) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "MrCharlesError";
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.status = status;
|
|
31
|
+
this.requestId = requestId;
|
|
32
|
+
}
|
|
33
|
+
get holdIsDead() {
|
|
34
|
+
return this.code === "HOLD_EXPIRED" || this.code === "TOO_MANY_ATTEMPTS";
|
|
35
|
+
}
|
|
36
|
+
get slotIsGone() {
|
|
37
|
+
return this.code === "SLOT_TAKEN" || this.code === "OUTSIDE_HOURS" || this.code === "OUTSIDE_BOOKING_WINDOW";
|
|
38
|
+
}
|
|
39
|
+
get isTransient() {
|
|
40
|
+
return this.code === "NETWORK_ERROR" || this.status >= 500;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function isMrCharlesError(e) {
|
|
44
|
+
return e instanceof MrCharlesError || typeof e === "object" && e !== null && e.name === "MrCharlesError";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/http.ts
|
|
48
|
+
function createHttp(apiBase, config) {
|
|
49
|
+
const resolved = config.fetch ?? (typeof fetch === "function" ? fetch.bind(globalThis) : undefined);
|
|
50
|
+
if (!resolved)
|
|
51
|
+
throw new Error("@mrcharles/sdk: no fetch available — pass { fetch } in the client config");
|
|
52
|
+
const doFetch = resolved;
|
|
53
|
+
async function request(method, path, body, params) {
|
|
54
|
+
let url = apiBase + path;
|
|
55
|
+
if (params) {
|
|
56
|
+
const q = new URLSearchParams;
|
|
57
|
+
for (const [k, v] of Object.entries(params))
|
|
58
|
+
if (v !== undefined && v !== "")
|
|
59
|
+
q.set(k, String(v));
|
|
60
|
+
const s = q.toString();
|
|
61
|
+
if (s)
|
|
62
|
+
url += (url.includes("?") ? "&" : "?") + s;
|
|
63
|
+
}
|
|
64
|
+
const headers = { Accept: "application/json", ...config.headers ?? {} };
|
|
65
|
+
if (config.locale)
|
|
66
|
+
headers["Accept-Language"] = config.locale;
|
|
67
|
+
if (body !== undefined)
|
|
68
|
+
headers["Content-Type"] = "application/json";
|
|
69
|
+
let res;
|
|
70
|
+
try {
|
|
71
|
+
res = await doFetch(url, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, cache: "no-store" });
|
|
72
|
+
} catch (e) {
|
|
73
|
+
throw new MrCharlesError("NETWORK_ERROR", e?.message || "Network error", 0);
|
|
74
|
+
}
|
|
75
|
+
const requestId = res.headers?.get?.("x-request-id") ?? undefined;
|
|
76
|
+
const text = await res.text();
|
|
77
|
+
let json = null;
|
|
78
|
+
try {
|
|
79
|
+
json = text ? JSON.parse(text) : null;
|
|
80
|
+
} catch {
|
|
81
|
+
json = null;
|
|
82
|
+
}
|
|
83
|
+
const envelope = json && typeof json === "object" ? json : {};
|
|
84
|
+
if (!res.ok || envelope.success === false) {
|
|
85
|
+
const code = envelope.error?.code ?? (res.status === 429 ? "RATE_LIMIT_EXCEEDED" : res.status === 404 ? "RESOURCE_NOT_FOUND" : "INTERNAL_ERROR");
|
|
86
|
+
const message = envelope.error?.message ?? res.statusText ?? "Request failed";
|
|
87
|
+
throw new MrCharlesError(code, message, res.status, requestId);
|
|
88
|
+
}
|
|
89
|
+
return "data" in envelope ? envelope.data : json;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
get: (path, params) => request("GET", path, undefined, params),
|
|
93
|
+
post: (path, body) => request("POST", path, body ?? {})
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/links.ts
|
|
98
|
+
function createLinks(endpoints) {
|
|
99
|
+
const siteOrigin = (slug) => `https://${slug}.${endpoints.sitesBase}`;
|
|
100
|
+
return {
|
|
101
|
+
site(slug) {
|
|
102
|
+
return `${siteOrigin(slug)}/`;
|
|
103
|
+
},
|
|
104
|
+
bookRoom(o) {
|
|
105
|
+
const q = new URLSearchParams;
|
|
106
|
+
if (o.date)
|
|
107
|
+
q.set("date", o.date);
|
|
108
|
+
if (o.start)
|
|
109
|
+
q.set("start", o.start);
|
|
110
|
+
if (o.durationMinutes)
|
|
111
|
+
q.set("duration", String(o.durationMinutes));
|
|
112
|
+
if (o.lang)
|
|
113
|
+
q.set("lang", o.lang);
|
|
114
|
+
if (o.embed)
|
|
115
|
+
q.set("embed", "1");
|
|
116
|
+
const s = q.toString();
|
|
117
|
+
return `${siteOrigin(o.slug)}/reservar/${o.roomId}${s ? `?${s}` : ""}`;
|
|
118
|
+
},
|
|
119
|
+
manage(slug, manageToken) {
|
|
120
|
+
return `${siteOrigin(slug)}/r/${manageToken}`;
|
|
121
|
+
},
|
|
122
|
+
bookLesson(o) {
|
|
123
|
+
const q = new URLSearchParams({ instructorId: o.instructorId });
|
|
124
|
+
if (o.offerId)
|
|
125
|
+
q.set("offerId", o.offerId);
|
|
126
|
+
return `${endpoints.appBase}/bookings/book?${q.toString()}`;
|
|
127
|
+
},
|
|
128
|
+
teacherProfile(instructorUserId) {
|
|
129
|
+
return `${endpoints.appBase}/instructors/${instructorUserId}`;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/embed.ts
|
|
135
|
+
function isTrustedOrigin(origin, sitesBase) {
|
|
136
|
+
try {
|
|
137
|
+
const host = new URL(origin).host;
|
|
138
|
+
return host === sitesBase || host.endsWith(`.${sitesBase}`);
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function parseEmbedEvent(data) {
|
|
144
|
+
if (!data || typeof data !== "object")
|
|
145
|
+
return null;
|
|
146
|
+
const d = data;
|
|
147
|
+
if (d.type === "mrcharles:resize" && typeof d.height === "number")
|
|
148
|
+
return { type: "mrcharles:resize", height: d.height };
|
|
149
|
+
if (d.type === "mrcharles:booked" && typeof d.reference === "string") {
|
|
150
|
+
return {
|
|
151
|
+
type: "mrcharles:booked",
|
|
152
|
+
reference: d.reference,
|
|
153
|
+
bookingId: String(d.bookingId ?? ""),
|
|
154
|
+
manageUrl: String(d.manageUrl ?? ""),
|
|
155
|
+
status: String(d.status ?? "")
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
function createEmbed(endpoints) {
|
|
161
|
+
const links = createLinks(endpoints);
|
|
162
|
+
function mountRoomBooking(target, opts) {
|
|
163
|
+
const el = typeof target === "string" ? document.querySelector(target) : target;
|
|
164
|
+
if (!el)
|
|
165
|
+
throw new Error(`@mrcharles/sdk: embed target not found: ${String(target)}`);
|
|
166
|
+
const iframe = document.createElement("iframe");
|
|
167
|
+
iframe.src = links.bookRoom({ ...opts, embed: true });
|
|
168
|
+
iframe.title = opts.title ?? "Mr Charles booking";
|
|
169
|
+
iframe.loading = "lazy";
|
|
170
|
+
iframe.referrerPolicy = "strict-origin-when-cross-origin";
|
|
171
|
+
iframe.setAttribute("allow", "payment");
|
|
172
|
+
iframe.style.width = "100%";
|
|
173
|
+
iframe.style.border = "0";
|
|
174
|
+
iframe.style.display = "block";
|
|
175
|
+
iframe.style.height = `${opts.height ?? 760}px`;
|
|
176
|
+
if (opts.className)
|
|
177
|
+
iframe.className = opts.className;
|
|
178
|
+
const expectedOrigin = new URL(iframe.src).origin;
|
|
179
|
+
const onMessage = (e) => {
|
|
180
|
+
if (e.source !== iframe.contentWindow)
|
|
181
|
+
return;
|
|
182
|
+
if (e.origin !== expectedOrigin && !isTrustedOrigin(e.origin, endpoints.sitesBase))
|
|
183
|
+
return;
|
|
184
|
+
const ev = parseEmbedEvent(e.data);
|
|
185
|
+
if (!ev)
|
|
186
|
+
return;
|
|
187
|
+
if (ev.type === "mrcharles:resize") {
|
|
188
|
+
if (opts.autoResize !== false)
|
|
189
|
+
iframe.style.height = `${ev.height}px`;
|
|
190
|
+
opts.onResize?.(ev.height);
|
|
191
|
+
} else {
|
|
192
|
+
opts.onBooked?.(ev);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
window.addEventListener("message", onMessage);
|
|
196
|
+
el.appendChild(iframe);
|
|
197
|
+
return {
|
|
198
|
+
iframe,
|
|
199
|
+
destroy() {
|
|
200
|
+
window.removeEventListener("message", onMessage);
|
|
201
|
+
iframe.remove();
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return { mountRoomBooking };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// src/uuid.ts
|
|
209
|
+
function uuid() {
|
|
210
|
+
const c = globalThis.crypto;
|
|
211
|
+
if (c?.randomUUID)
|
|
212
|
+
return c.randomUUID();
|
|
213
|
+
const b = new Uint8Array(16);
|
|
214
|
+
if (c?.getRandomValues)
|
|
215
|
+
c.getRandomValues(b);
|
|
216
|
+
else
|
|
217
|
+
for (let i = 0;i < 16; i++)
|
|
218
|
+
b[i] = Math.floor(Math.random() * 256);
|
|
219
|
+
b[6] = b[6] & 15 | 64;
|
|
220
|
+
b[8] = b[8] & 63 | 128;
|
|
221
|
+
const h = [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
|
|
222
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/slots.ts
|
|
226
|
+
function toMinutes(hhmm) {
|
|
227
|
+
const [h, m] = hhmm.split(":").map(Number);
|
|
228
|
+
return (h || 0) * 60 + (m || 0);
|
|
229
|
+
}
|
|
230
|
+
function toHHMM(minutes) {
|
|
231
|
+
const h = Math.floor(minutes / 60);
|
|
232
|
+
const m = minutes % 60;
|
|
233
|
+
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
|
234
|
+
}
|
|
235
|
+
function addMinutes(hhmm, minutes) {
|
|
236
|
+
return toHHMM(toMinutes(hhmm) + minutes);
|
|
237
|
+
}
|
|
238
|
+
function startTimes(free, durationMinutes, stepMinutes) {
|
|
239
|
+
const step = Math.max(1, stepMinutes);
|
|
240
|
+
const out = new Set;
|
|
241
|
+
for (const span of free) {
|
|
242
|
+
const s = toMinutes(span.start);
|
|
243
|
+
const e = toMinutes(span.end);
|
|
244
|
+
for (let t = Math.ceil(s / step) * step;t + durationMinutes <= e; t += step)
|
|
245
|
+
out.add(toHHMM(t));
|
|
246
|
+
}
|
|
247
|
+
return [...out].sort((a, b) => toMinutes(a) - toMinutes(b));
|
|
248
|
+
}
|
|
249
|
+
function durationOptions(free, start, minMinutes, maxMinutes, stepMinutes) {
|
|
250
|
+
const s = toMinutes(start);
|
|
251
|
+
const span = free.find((f) => toMinutes(f.start) <= s && s < toMinutes(f.end));
|
|
252
|
+
if (!span)
|
|
253
|
+
return [];
|
|
254
|
+
const room = toMinutes(span.end) - s;
|
|
255
|
+
const out = [];
|
|
256
|
+
for (let d = minMinutes;d <= maxMinutes && d <= room; d += Math.max(1, stepMinutes))
|
|
257
|
+
out.push(d);
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
function formatPrice(cents, currency, locale = "es-ES") {
|
|
261
|
+
try {
|
|
262
|
+
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(cents / 100);
|
|
263
|
+
} catch {
|
|
264
|
+
return `${(cents / 100).toFixed(2)} ${currency}`;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function dateKey(date, timeZone) {
|
|
268
|
+
const parts = new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(date);
|
|
269
|
+
const get = (t) => parts.find((p) => p.type === t)?.value ?? "";
|
|
270
|
+
return `${get("year")}-${get("month")}-${get("day")}`;
|
|
271
|
+
}
|
|
272
|
+
function addDays(key, days) {
|
|
273
|
+
const [y, m, d] = key.split("-").map(Number);
|
|
274
|
+
const t = Date.UTC(y, m - 1, d + days);
|
|
275
|
+
return new Date(t).toISOString().slice(0, 10);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// src/rooms.ts
|
|
279
|
+
function createRoomBooking(http, links, opts) {
|
|
280
|
+
let state = { step: "slot", site: null, room: null, availability: null, hold: null, confirmation: null, error: null, busy: false };
|
|
281
|
+
const listeners = new Set;
|
|
282
|
+
if (opts.onChange)
|
|
283
|
+
listeners.add(opts.onChange);
|
|
284
|
+
let idempotencyKey = uuid();
|
|
285
|
+
let expiryTimer;
|
|
286
|
+
const set = (patch) => {
|
|
287
|
+
state = { ...state, ...patch };
|
|
288
|
+
for (const l of listeners)
|
|
289
|
+
l(state);
|
|
290
|
+
};
|
|
291
|
+
const clearTimer = () => {
|
|
292
|
+
if (expiryTimer)
|
|
293
|
+
clearTimeout(expiryTimer);
|
|
294
|
+
expiryTimer = undefined;
|
|
295
|
+
};
|
|
296
|
+
const armTimer = (hold) => {
|
|
297
|
+
clearTimer();
|
|
298
|
+
const ms = new Date(hold.expiresAt).getTime() - Date.now();
|
|
299
|
+
if (ms <= 0)
|
|
300
|
+
return;
|
|
301
|
+
expiryTimer = setTimeout(() => {
|
|
302
|
+
if (state.step === "verify" || state.step === "pay") {
|
|
303
|
+
idempotencyKey = uuid();
|
|
304
|
+
set({ step: "expired", error: new MrCharlesError("HOLD_EXPIRED", "The hold expired. Pick a time again.", 410) });
|
|
305
|
+
}
|
|
306
|
+
}, ms);
|
|
307
|
+
};
|
|
308
|
+
async function run(fn) {
|
|
309
|
+
set({ busy: true });
|
|
310
|
+
try {
|
|
311
|
+
const out = await fn();
|
|
312
|
+
set({ busy: false, error: null });
|
|
313
|
+
return out;
|
|
314
|
+
} catch (e) {
|
|
315
|
+
const err = isMrCharlesError(e) ? e : new MrCharlesError("INTERNAL_ERROR", e?.message ?? "Unexpected error");
|
|
316
|
+
if (err.holdIsDead) {
|
|
317
|
+
clearTimer();
|
|
318
|
+
idempotencyKey = uuid();
|
|
319
|
+
set({ busy: false, error: err, step: "expired", hold: null });
|
|
320
|
+
} else {
|
|
321
|
+
set({ busy: false, error: err });
|
|
322
|
+
}
|
|
323
|
+
throw err;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const api = {
|
|
327
|
+
get state() {
|
|
328
|
+
return state;
|
|
329
|
+
},
|
|
330
|
+
subscribe(fn) {
|
|
331
|
+
listeners.add(fn);
|
|
332
|
+
fn(state);
|
|
333
|
+
return () => {
|
|
334
|
+
listeners.delete(fn);
|
|
335
|
+
};
|
|
336
|
+
},
|
|
337
|
+
loadSite: () => run(async () => {
|
|
338
|
+
const site = await http.get(`/public/sites/${encodeURIComponent(opts.slug)}`);
|
|
339
|
+
const room = site.rooms.find((r) => r.id === opts.roomId) ?? null;
|
|
340
|
+
if (!room)
|
|
341
|
+
throw new MrCharlesError("RESOURCE_NOT_FOUND", `Room ${opts.roomId} is not on site ${opts.slug}`, 404);
|
|
342
|
+
if (!room.bookable)
|
|
343
|
+
throw new MrCharlesError("ROOM_NOT_BOOKABLE", `${room.name} does not take online bookings right now`, 409);
|
|
344
|
+
set({ site, room });
|
|
345
|
+
return site;
|
|
346
|
+
}),
|
|
347
|
+
loadAvailability: (window2 = {}) => run(async () => {
|
|
348
|
+
const tz = state.site?.studio.timezone ?? "UTC";
|
|
349
|
+
const from = window2.from ?? dateKey(new Date, tz);
|
|
350
|
+
const to = window2.to ?? addDays(from, Math.min(31, Math.max(1, window2.days ?? 14)) - 1);
|
|
351
|
+
const availability = await http.get(`/public/sites/${encodeURIComponent(opts.slug)}/rooms/${encodeURIComponent(opts.roomId)}/availability`, { from, to });
|
|
352
|
+
set({ availability });
|
|
353
|
+
return availability;
|
|
354
|
+
}),
|
|
355
|
+
startTimesFor(date, durationMinutes) {
|
|
356
|
+
const a = state.availability;
|
|
357
|
+
if (!a)
|
|
358
|
+
return [];
|
|
359
|
+
const day = a.days.find((d) => d.date === date);
|
|
360
|
+
return day ? startTimes(day.free, durationMinutes, a.stepMinutes) : [];
|
|
361
|
+
},
|
|
362
|
+
durationsFor(date, startTime) {
|
|
363
|
+
const a = state.availability;
|
|
364
|
+
if (!a)
|
|
365
|
+
return [];
|
|
366
|
+
const day = a.days.find((d) => d.date === date);
|
|
367
|
+
return day ? durationOptions(day.free, startTime, a.minMinutes, a.maxMinutes, a.stepMinutes) : [];
|
|
368
|
+
},
|
|
369
|
+
hold: (details) => run(async () => {
|
|
370
|
+
if (state.step === "expired")
|
|
371
|
+
set({ step: "slot", error: null });
|
|
372
|
+
const body = {
|
|
373
|
+
roomId: opts.roomId,
|
|
374
|
+
date: details.date,
|
|
375
|
+
startTime: details.startTime,
|
|
376
|
+
durationMinutes: details.durationMinutes,
|
|
377
|
+
guestName: details.guestName,
|
|
378
|
+
channel: details.channel,
|
|
379
|
+
contact: details.contact,
|
|
380
|
+
locale: details.locale ?? state.site?.site.defaultLocale ?? "es",
|
|
381
|
+
idempotencyKey,
|
|
382
|
+
...details.captchaToken ? { captchaToken: details.captchaToken } : {}
|
|
383
|
+
};
|
|
384
|
+
const hold = await http.post(`/public/sites/${encodeURIComponent(opts.slug)}/holds`, body);
|
|
385
|
+
armTimer(hold);
|
|
386
|
+
set({ hold, step: "verify", confirmation: null });
|
|
387
|
+
return hold;
|
|
388
|
+
}),
|
|
389
|
+
verify: (code) => run(async () => {
|
|
390
|
+
const id = requireHold(state).holdId;
|
|
391
|
+
const hold = await http.post(`/public/holds/${id}/verify`, { code: code.replace(/\D/g, "") });
|
|
392
|
+
set({ hold, step: "pay" });
|
|
393
|
+
return hold;
|
|
394
|
+
}),
|
|
395
|
+
resend: () => run(async () => {
|
|
396
|
+
const id = requireHold(state).holdId;
|
|
397
|
+
const hold = await http.post(`/public/holds/${id}/resend`);
|
|
398
|
+
set({ hold });
|
|
399
|
+
return hold;
|
|
400
|
+
}),
|
|
401
|
+
confirm: (req) => run(async () => {
|
|
402
|
+
const h = requireHold(state);
|
|
403
|
+
if (!h.paymentModes.includes(req.paymentMode)) {
|
|
404
|
+
throw new MrCharlesError("PAYMENT_MODE_DENIED", `This room does not accept ${req.paymentMode}`, 409);
|
|
405
|
+
}
|
|
406
|
+
const confirmation = await http.post(`/public/holds/${h.holdId}/confirm`, {
|
|
407
|
+
paymentMode: req.paymentMode,
|
|
408
|
+
notes: req.notes ?? "",
|
|
409
|
+
email: req.email ?? ""
|
|
410
|
+
});
|
|
411
|
+
clearTimer();
|
|
412
|
+
if (!confirmation.manageUrl && confirmation.manageToken)
|
|
413
|
+
confirmation.manageUrl = links.manage(opts.slug, confirmation.manageToken);
|
|
414
|
+
set({ confirmation, step: "done" });
|
|
415
|
+
return confirmation;
|
|
416
|
+
}),
|
|
417
|
+
remainingMs() {
|
|
418
|
+
const h = state.hold;
|
|
419
|
+
if (!h || state.step === "done" || state.step === "expired")
|
|
420
|
+
return 0;
|
|
421
|
+
return Math.max(0, new Date(h.expiresAt).getTime() - Date.now());
|
|
422
|
+
},
|
|
423
|
+
reset() {
|
|
424
|
+
clearTimer();
|
|
425
|
+
idempotencyKey = uuid();
|
|
426
|
+
set({ step: "slot", hold: null, confirmation: null, error: null, busy: false });
|
|
427
|
+
},
|
|
428
|
+
destroy() {
|
|
429
|
+
clearTimer();
|
|
430
|
+
listeners.clear();
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
return api;
|
|
434
|
+
}
|
|
435
|
+
function requireHold(s) {
|
|
436
|
+
if (!s.hold)
|
|
437
|
+
throw new MrCharlesError("INVALID_STATE", "No hold yet — call hold() first", 409);
|
|
438
|
+
return s.hold;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/client.ts
|
|
442
|
+
function createClient(config = {}) {
|
|
443
|
+
const endpoints = resolveEndpoints(config);
|
|
444
|
+
const http = createHttp(endpoints.apiBase, config);
|
|
445
|
+
const links = createLinks(endpoints);
|
|
446
|
+
const embed = createEmbed(endpoints);
|
|
447
|
+
const enc = encodeURIComponent;
|
|
448
|
+
const sites = {
|
|
449
|
+
get: (slug) => http.get(`/public/sites/${enc(slug)}`),
|
|
450
|
+
resolve: (host) => http.get("/public/sites/resolve", { host }),
|
|
451
|
+
availability: (slug, roomId, range) => http.get(`/public/sites/${enc(slug)}/rooms/${enc(roomId)}/availability`, range)
|
|
452
|
+
};
|
|
453
|
+
const holds = {
|
|
454
|
+
create: (slug, req) => http.post(`/public/sites/${enc(slug)}/holds`, req),
|
|
455
|
+
verify: (holdId, code) => http.post(`/public/holds/${enc(holdId)}/verify`, { code }),
|
|
456
|
+
resend: (holdId) => http.post(`/public/holds/${enc(holdId)}/resend`),
|
|
457
|
+
confirm: (holdId, req) => http.post(`/public/holds/${enc(holdId)}/confirm`, { paymentMode: req.paymentMode, notes: req.notes ?? "", email: req.email ?? "" })
|
|
458
|
+
};
|
|
459
|
+
const bookings = {
|
|
460
|
+
get: (manageToken) => http.get(`/public/bookings/manage/${enc(manageToken)}`),
|
|
461
|
+
cancel: (manageToken, reason = "") => http.post(`/public/bookings/manage/${enc(manageToken)}/cancel`, { reason })
|
|
462
|
+
};
|
|
463
|
+
const teachers = {
|
|
464
|
+
profile: (instructorUserId) => http.get(`/instructors/${enc(instructorUserId)}/teacher-profile`),
|
|
465
|
+
offers: (instructorUserId) => http.get(`/instructors/${enc(instructorUserId)}/offers`),
|
|
466
|
+
offer: (offerId) => http.get(`/offers/${enc(offerId)}`),
|
|
467
|
+
windows: (instructorUserId, range) => http.get(`/availability/${enc(instructorUserId)}/windows`, range),
|
|
468
|
+
policy: (instructorUserId) => http.get(`/availability/${enc(instructorUserId)}/policy`),
|
|
469
|
+
slots: (q) => http.get("/bookings/slots", { instructorId: q.instructorUserId, offerId: q.offerId, date: q.date, days: q.days ?? 7 })
|
|
470
|
+
};
|
|
471
|
+
return {
|
|
472
|
+
endpoints,
|
|
473
|
+
sites,
|
|
474
|
+
holds,
|
|
475
|
+
bookings,
|
|
476
|
+
teachers,
|
|
477
|
+
links,
|
|
478
|
+
mountRoomBooking: embed.mountRoomBooking,
|
|
479
|
+
roomBooking: (opts) => createRoomBooking(http, links, opts)
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// src/index.ts
|
|
484
|
+
var VERSION = "0.1.0";
|
|
485
|
+
export {
|
|
486
|
+
ENVIRONMENTS,
|
|
487
|
+
MrCharlesError,
|
|
488
|
+
VERSION,
|
|
489
|
+
addDays,
|
|
490
|
+
addMinutes,
|
|
491
|
+
createClient,
|
|
492
|
+
createEmbed,
|
|
493
|
+
createLinks,
|
|
494
|
+
createRoomBooking,
|
|
495
|
+
dateKey,
|
|
496
|
+
durationOptions,
|
|
497
|
+
formatPrice,
|
|
498
|
+
isMrCharlesError,
|
|
499
|
+
isTrustedOrigin,
|
|
500
|
+
parseEmbedEvent,
|
|
501
|
+
resolveEndpoints,
|
|
502
|
+
startTimes,
|
|
503
|
+
toHHMM,
|
|
504
|
+
toMinutes,
|
|
505
|
+
uuid
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
//# debugId=147361D96F83431064756E2164756E21
|
|
509
|
+
//# sourceMappingURL=mrcharles.esm.js.map
|