@patientos/website-kit 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.
@@ -0,0 +1,1240 @@
1
+ // src/portal-booking.client.tsx
2
+ import * as React4 from "react";
3
+
4
+ // src/portal-shared.client.tsx
5
+ import * as React from "react";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ var PORTAL_MAGIC_LINK_URL = "/portal/api/magic-link";
8
+ var PORTAL_SIGN_OUT_URL = "/portal/api/sign-out";
9
+ var PORTAL_REQUEST_INIT = { credentials: "include", cache: "no-store" };
10
+ function portalFetch(fetchImpl) {
11
+ return fetchImpl ?? ((input, init) => fetch(input, init));
12
+ }
13
+ async function readPortalResult(res) {
14
+ if (!res.ok) {
15
+ let error = null;
16
+ let body;
17
+ try {
18
+ const parsed = await res.json();
19
+ if (typeof parsed?.error === "string") error = parsed.error;
20
+ body = parsed;
21
+ } catch {
22
+ }
23
+ return { ok: false, status: res.status, error, body };
24
+ }
25
+ try {
26
+ return { ok: true, data: await res.json() };
27
+ } catch {
28
+ return { ok: false, status: null, error: null };
29
+ }
30
+ }
31
+ async function portalGet(url, fetchImpl) {
32
+ try {
33
+ return await readPortalResult(await portalFetch(fetchImpl)(url, PORTAL_REQUEST_INIT));
34
+ } catch {
35
+ return { ok: false, status: null, error: null };
36
+ }
37
+ }
38
+ async function portalSend(url, method, body, fetchImpl) {
39
+ try {
40
+ const res = await portalFetch(fetchImpl)(url, {
41
+ ...PORTAL_REQUEST_INIT,
42
+ method,
43
+ headers: { "content-type": "application/json" },
44
+ body: JSON.stringify(body ?? {})
45
+ });
46
+ return await readPortalResult(res);
47
+ } catch {
48
+ return { ok: false, status: null, error: null };
49
+ }
50
+ }
51
+ async function portalDelete(url, fetchImpl) {
52
+ try {
53
+ const res = await portalFetch(fetchImpl)(url, { ...PORTAL_REQUEST_INIT, method: "DELETE" });
54
+ return await readPortalResult(res);
55
+ } catch {
56
+ return { ok: false, status: null, error: null };
57
+ }
58
+ }
59
+ function hasPortalIdentity(body) {
60
+ return typeof body?.signedIn === "boolean";
61
+ }
62
+ var SIGNED_IN_HINT_KEY = "patientos.portal.signedIn";
63
+ function readPortalSignedInHint() {
64
+ try {
65
+ return globalThis.sessionStorage?.getItem(SIGNED_IN_HINT_KEY) === "1";
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+ function writePortalSignedInHint(signedIn) {
71
+ try {
72
+ const store = globalThis.sessionStorage;
73
+ if (!store) return;
74
+ if (signedIn) store.setItem(SIGNED_IN_HINT_KEY, "1");
75
+ else store.removeItem(SIGNED_IN_HINT_KEY);
76
+ } catch {
77
+ }
78
+ }
79
+ async function requestPortalSignInLink(email, redirect, fetchImpl) {
80
+ const res = await portalSend(
81
+ PORTAL_MAGIC_LINK_URL,
82
+ "POST",
83
+ { email, redirect },
84
+ fetchImpl
85
+ );
86
+ return { ok: res.ok };
87
+ }
88
+ async function signOutOfPortal(fetchImpl) {
89
+ const res = await portalSend(PORTAL_SIGN_OUT_URL, "POST", {}, fetchImpl);
90
+ return { ok: res.ok };
91
+ }
92
+ function currentPagePath() {
93
+ if (typeof window === "undefined") return "/";
94
+ return window.location.pathname || "/";
95
+ }
96
+ var LOCALE = "en-AU";
97
+ function parseInstant(iso) {
98
+ if (!iso) return null;
99
+ const d = new Date(iso);
100
+ return Number.isNaN(d.getTime()) ? null : d;
101
+ }
102
+ function formatPortalDate(iso, timeZone) {
103
+ const d = parseInstant(iso);
104
+ if (!d) return null;
105
+ const opts = { day: "numeric", month: "short", year: "numeric" };
106
+ try {
107
+ return new Intl.DateTimeFormat(LOCALE, { ...opts, timeZone: timeZone ?? void 0 }).format(d);
108
+ } catch {
109
+ return new Intl.DateTimeFormat(LOCALE, opts).format(d);
110
+ }
111
+ }
112
+ function formatPortalDateTime(iso, timeZone) {
113
+ const d = parseInstant(iso);
114
+ if (!d) return null;
115
+ const opts = {
116
+ weekday: "short",
117
+ day: "numeric",
118
+ month: "short",
119
+ year: "numeric",
120
+ hour: "numeric",
121
+ minute: "2-digit"
122
+ };
123
+ try {
124
+ return new Intl.DateTimeFormat(LOCALE, { ...opts, timeZone: timeZone ?? void 0 }).format(d);
125
+ } catch {
126
+ return new Intl.DateTimeFormat(LOCALE, opts).format(d);
127
+ }
128
+ }
129
+ function formatPortalTime(iso, timeZone) {
130
+ const d = parseInstant(iso);
131
+ if (!d) return null;
132
+ const opts = { hour: "numeric", minute: "2-digit" };
133
+ try {
134
+ return new Intl.DateTimeFormat(LOCALE, { ...opts, timeZone: timeZone ?? void 0 }).format(d);
135
+ } catch {
136
+ return new Intl.DateTimeFormat(LOCALE, opts).format(d);
137
+ }
138
+ }
139
+ function formatPortalMoney(total, currency) {
140
+ if (total === null || total === void 0 || total === "") return null;
141
+ const n = Number(total);
142
+ if (!Number.isFinite(n)) return String(total);
143
+ const code = (currency ?? "AUD").toUpperCase();
144
+ try {
145
+ return new Intl.NumberFormat(LOCALE, { style: "currency", currency: code }).format(n);
146
+ } catch {
147
+ return `${code} ${n.toFixed(2)}`;
148
+ }
149
+ }
150
+ function PortalSignInRamp({
151
+ idSuffix,
152
+ submit,
153
+ onResult,
154
+ intro,
155
+ submitLabel
156
+ }) {
157
+ const [email, setEmail] = React.useState("");
158
+ const [busy, setBusy] = React.useState(false);
159
+ const inputId = `sk-portal-email-${idSuffix}`;
160
+ async function onSubmit(e) {
161
+ e.preventDefault();
162
+ if (!email || busy) return;
163
+ setBusy(true);
164
+ const { ok } = await submit(email);
165
+ setBusy(false);
166
+ onResult({ ok, email });
167
+ }
168
+ return /* @__PURE__ */ jsxs("form", { className: "sk-portal-ramp", onSubmit: (e) => void onSubmit(e), children: [
169
+ /* @__PURE__ */ jsx("p", { className: "sk-portal-ramp__intro", children: intro ?? "Enter your email and we'll send you a sign-in link." }),
170
+ /* @__PURE__ */ jsx("label", { className: "sk-portal-label", htmlFor: inputId, children: "Email" }),
171
+ /* @__PURE__ */ jsx(
172
+ "input",
173
+ {
174
+ id: inputId,
175
+ className: "sk-portal-input",
176
+ type: "email",
177
+ name: "email",
178
+ required: true,
179
+ autoComplete: "email",
180
+ value: email,
181
+ onChange: (e) => setEmail(e.target.value)
182
+ }
183
+ ),
184
+ /* @__PURE__ */ jsx("button", { className: "sk-portal-button", type: "submit", disabled: busy, children: busy ? "Sending\u2026" : submitLabel ?? "Email me a sign-in link" })
185
+ ] });
186
+ }
187
+ function PortalLinkSentNotice({ email }) {
188
+ return /* @__PURE__ */ jsxs("p", { className: "sk-portal-ramp__sent", role: "status", children: [
189
+ "If ",
190
+ email,
191
+ " is on file with us, a sign-in link is on its way. It expires in a few minutes."
192
+ ] });
193
+ }
194
+
195
+ // src/slot-picker.tsx
196
+ import * as React3 from "react";
197
+
198
+ // src/flow-shell.tsx
199
+ import * as React2 from "react";
200
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
201
+ var FlowRailContext = React2.createContext(null);
202
+ function FlowShell({
203
+ eyebrow,
204
+ heading,
205
+ subheading,
206
+ stepKey,
207
+ stepNumber,
208
+ totalSteps,
209
+ onBack,
210
+ documentTitle,
211
+ footer,
212
+ children,
213
+ className
214
+ }) {
215
+ const rail = React2.useContext(FlowRailContext);
216
+ const headingRef = React2.useRef(null);
217
+ const mounted = React2.useRef(false);
218
+ React2.useEffect(() => {
219
+ if (!mounted.current) {
220
+ mounted.current = true;
221
+ return;
222
+ }
223
+ headingRef.current?.focus();
224
+ }, [stepKey]);
225
+ React2.useEffect(() => {
226
+ if (documentTitle && typeof document !== "undefined") document.title = documentTitle;
227
+ }, [documentTitle]);
228
+ const [rootEl, setRootEl] = React2.useState(null);
229
+ useResolvedSurface(rootEl);
230
+ const place = Boolean(eyebrow) || Boolean(stepNumber && totalSteps);
231
+ return /* @__PURE__ */ jsxs2(
232
+ "div",
233
+ {
234
+ ref: setRootEl,
235
+ className: ["sk-flow", rail ? "sk-flow--railed" : null, className].filter(Boolean).join(" "),
236
+ "data-sk-step": stepKey,
237
+ children: [
238
+ /* @__PURE__ */ jsxs2("header", { className: "sk-flow__bar", children: [
239
+ onBack ? /* @__PURE__ */ jsxs2("button", { type: "button", className: "sk-flow__back", onClick: onBack, "data-sk-flow-back": "", children: [
240
+ /* @__PURE__ */ jsx2("span", { "aria-hidden": "true", children: "\u2190" }),
241
+ " Back"
242
+ ] }) : place ? (
243
+ // Only earns its 36px when there IS something on the right to push over.
244
+ /* @__PURE__ */ jsx2("span", { className: "sk-flow__back-spacer", "aria-hidden": "true" })
245
+ ) : null,
246
+ place ? /* @__PURE__ */ jsxs2("div", { className: "sk-flow__place", children: [
247
+ eyebrow && /* @__PURE__ */ jsx2("p", { className: "sk-flow__eyebrow", children: eyebrow }),
248
+ stepNumber && totalSteps ? /* @__PURE__ */ jsx2(Progress, { current: stepNumber, total: totalSteps }) : null
249
+ ] }) : null
250
+ ] }),
251
+ /* @__PURE__ */ jsxs2("main", { className: "sk-flow__body", children: [
252
+ (heading || subheading) && /* @__PURE__ */ jsxs2("div", { className: "sk-flow__head", children: [
253
+ heading && // tabIndex -1 makes a non-interactive heading programmatically focusable.
254
+ /* @__PURE__ */ jsx2("h2", { ref: headingRef, tabIndex: -1, className: "sk-flow__heading", children: heading }),
255
+ subheading && /* @__PURE__ */ jsx2("div", { className: "sk-flow__sub", children: subheading })
256
+ ] }),
257
+ /* @__PURE__ */ jsx2("p", { className: "sk-flow__status", role: "status", "aria-live": "polite", children: eyebrow ?? "" }),
258
+ children
259
+ ] }),
260
+ footer && /* @__PURE__ */ jsx2("footer", { className: "sk-flow__footer", children: footer }),
261
+ rail ? (
262
+ // Named, or a screen reader announces a bare "complementary" landmark and the
263
+ // patient has to enter it to find out whether it is worth reading.
264
+ /* @__PURE__ */ jsx2("aside", { className: "sk-flow__rail", "aria-label": "About this clinic", children: rail })
265
+ ) : null
266
+ ]
267
+ }
268
+ );
269
+ }
270
+ function useResolvedSurface(el) {
271
+ React2.useEffect(() => {
272
+ if (!el || typeof window === "undefined") return;
273
+ const apply = () => {
274
+ const ink = window.getComputedStyle(el).color;
275
+ if (ink) el.style.setProperty("--sk-flow-ink", ink);
276
+ el.style.setProperty("--sk-flow-canvas", opaqueBackdrop(el));
277
+ };
278
+ apply();
279
+ const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
280
+ mq?.addEventListener?.("change", apply);
281
+ return () => mq?.removeEventListener?.("change", apply);
282
+ }, [el]);
283
+ }
284
+ function opaqueBackdrop(el, read = (e) => window.getComputedStyle(e).backgroundColor) {
285
+ for (let node = el; node; node = node.parentElement) {
286
+ const bg = read(node);
287
+ if (bg && alphaOf(bg) === 1) return bg;
288
+ }
289
+ return "white";
290
+ }
291
+ function alphaOf(bg) {
292
+ if (bg === "transparent") return 0;
293
+ const inner = /^rgba?\(([^)]*)\)$/.exec(bg)?.[1];
294
+ if (inner === void 0) return 1;
295
+ const parts = inner.split(/[,/]/).map((s) => s.trim());
296
+ if (parts.length < 4) return 1;
297
+ const raw = parts[3];
298
+ const n = raw.endsWith("%") ? Number.parseFloat(raw) / 100 : Number.parseFloat(raw);
299
+ return Number.isFinite(n) ? n : 1;
300
+ }
301
+ function Progress({ current, total }) {
302
+ return /* @__PURE__ */ jsx2(
303
+ "div",
304
+ {
305
+ className: "sk-flow__progress",
306
+ role: "progressbar",
307
+ "aria-label": "Progress",
308
+ "aria-valuemin": 1,
309
+ "aria-valuemax": total,
310
+ "aria-valuenow": current,
311
+ "aria-valuetext": `Step ${current} of ${total}`,
312
+ children: Array.from({ length: total }, (_, i) => /* @__PURE__ */ jsx2(
313
+ "span",
314
+ {
315
+ "aria-hidden": "true",
316
+ className: [
317
+ "sk-flow__dot",
318
+ i + 1 === current ? "sk-flow__dot--current" : "",
319
+ i + 1 < current ? "sk-flow__dot--done" : ""
320
+ ].filter(Boolean).join(" ")
321
+ },
322
+ i
323
+ ))
324
+ }
325
+ );
326
+ }
327
+ function FlowButton({
328
+ variant = "primary",
329
+ busy,
330
+ children,
331
+ className,
332
+ ...rest
333
+ }) {
334
+ return /* @__PURE__ */ jsx2(
335
+ "button",
336
+ {
337
+ ...rest,
338
+ className: ["sk-flow__btn", `sk-flow__btn--${variant}`, className].filter(Boolean).join(" "),
339
+ "aria-busy": busy || void 0,
340
+ disabled: rest.disabled || busy,
341
+ children
342
+ }
343
+ );
344
+ }
345
+ var SummarisedErrorsContext = React2.createContext(false);
346
+ function SummarisedFieldErrors({
347
+ children
348
+ }) {
349
+ return /* @__PURE__ */ jsx2(SummarisedErrorsContext.Provider, { value: true, children });
350
+ }
351
+ function FlowError({
352
+ children,
353
+ id
354
+ }) {
355
+ const summarised = React2.useContext(SummarisedErrorsContext);
356
+ if (!children) return null;
357
+ return /* @__PURE__ */ jsx2("p", { className: "sk-flow__error", id, role: summarised ? void 0 : "alert", children });
358
+ }
359
+ function FlowFieldError({
360
+ id,
361
+ children
362
+ }) {
363
+ if (!children) return null;
364
+ return /* @__PURE__ */ jsx2("p", { className: "sk-flow__error", id, children });
365
+ }
366
+ function describedBy(...ids) {
367
+ return ids.filter(Boolean).join(" ") || void 0;
368
+ }
369
+ function invalidFields(fields, errors) {
370
+ return fields.filter((f) => Boolean(errors[f.key]));
371
+ }
372
+ function FlowErrorSummary({
373
+ fields
374
+ }) {
375
+ if (fields.length === 0) return null;
376
+ const names = fields.map((f) => f.label);
377
+ const list = names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
378
+ return /* @__PURE__ */ jsx2("p", { className: "sk-flow__error", role: "alert", children: fields.length === 1 ? `Please check one field: ${list}.` : `Please check ${fields.length} fields: ${list}.` });
379
+ }
380
+ function focusField(id) {
381
+ if (typeof document === "undefined") return false;
382
+ const el = document.getElementById(id);
383
+ if (!el || typeof el.focus !== "function") return false;
384
+ el.focus();
385
+ return true;
386
+ }
387
+ function FlowLoading({ message = "One moment\u2026" }) {
388
+ return /* @__PURE__ */ jsx2("p", { className: "sk-flow__loading", role: "status", "aria-live": "polite", children: message });
389
+ }
390
+
391
+ // src/public-client.ts
392
+ import {
393
+ createPatientOSClient,
394
+ PatientOSApiError
395
+ } from "@patientos/public-sdk";
396
+ var CLAIM_STORAGE_KEY = "pos:claim-token";
397
+ function session() {
398
+ try {
399
+ return typeof window === "undefined" ? null : window.sessionStorage;
400
+ } catch {
401
+ return null;
402
+ }
403
+ }
404
+ function readClaim() {
405
+ try {
406
+ return session()?.getItem(CLAIM_STORAGE_KEY) ?? null;
407
+ } catch {
408
+ return null;
409
+ }
410
+ }
411
+ function writeClaim(token) {
412
+ try {
413
+ const store = session();
414
+ if (!store) return;
415
+ if (token) store.setItem(CLAIM_STORAGE_KEY, token);
416
+ else store.removeItem(CLAIM_STORAGE_KEY);
417
+ } catch {
418
+ }
419
+ }
420
+ var client = null;
421
+ var clientKey = "";
422
+ var REQUEST_TIMEOUT_MS = 3e4;
423
+ function boundedFetch(input, init) {
424
+ const controller = new AbortController();
425
+ const upstreamSignal = init?.signal;
426
+ const abortFromUpstream = () => controller.abort();
427
+ if (upstreamSignal?.aborted) controller.abort();
428
+ else upstreamSignal?.addEventListener("abort", abortFromUpstream, { once: true });
429
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
430
+ return globalThis.fetch(input, { ...init, signal: controller.signal }).finally(() => {
431
+ clearTimeout(timer);
432
+ upstreamSignal?.removeEventListener("abort", abortFromUpstream);
433
+ });
434
+ }
435
+ function patientos(config) {
436
+ const key = `${config.publishableKey}|${config.apiBase}`;
437
+ if (!client || clientKey !== key) {
438
+ client = createPatientOSClient({
439
+ baseUrl: config.apiBase,
440
+ publishableKey: config.publishableKey,
441
+ claimToken: readClaim(),
442
+ fetch: boundedFetch
443
+ });
444
+ clientKey = key;
445
+ }
446
+ return client;
447
+ }
448
+ function persistClaimToken(c) {
449
+ writeClaim(c.getClaimToken());
450
+ }
451
+ function adoptClaimToken(c, token) {
452
+ c.setClaimToken(token);
453
+ writeClaim(token);
454
+ }
455
+ function resetSession() {
456
+ writeClaim(null);
457
+ client = null;
458
+ clientKey = "";
459
+ }
460
+ var flowKey = (requestId) => `pos:flow:${requestId}`;
461
+ function saveFlowRecord(requestId, record) {
462
+ try {
463
+ session()?.setItem(flowKey(requestId), JSON.stringify(record));
464
+ } catch {
465
+ }
466
+ }
467
+ function loadFlowRecord(requestId) {
468
+ try {
469
+ const raw = session()?.getItem(flowKey(requestId));
470
+ return raw ? JSON.parse(raw) : null;
471
+ } catch {
472
+ return null;
473
+ }
474
+ }
475
+ function once(subscribe) {
476
+ return new Promise((resolve) => {
477
+ const unsubscribe = subscribe((rows) => {
478
+ unsubscribe();
479
+ resolve(rows);
480
+ });
481
+ });
482
+ }
483
+ async function findServiceByKey(c, key) {
484
+ const services = await c.listServices();
485
+ return services.find((s) => s.key === key) ?? null;
486
+ }
487
+ function pickDeliveryOption(options, serviceId) {
488
+ const mine = options.filter((o) => o.serviceId === serviceId);
489
+ if (mine.length === 0) return null;
490
+ return mine.find((o) => o.isDefault) ?? [...mine].sort((a, b) => a.sortOrder - b.sortOrder)[0];
491
+ }
492
+ async function findDeliveryOption(c, serviceId) {
493
+ return pickDeliveryOption(await once(c.subscribeDeliveryOptions), serviceId);
494
+ }
495
+ function effectiveFee(svc, option) {
496
+ return { amount: option?.feeOverride ?? svc.feeAmount, requiresPayment: svc.requiresPayment };
497
+ }
498
+ function flowIsFree(flow) {
499
+ if (!flow) return null;
500
+ if (typeof flow.requiresPayment === "boolean") return !flow.requiresPayment;
501
+ return flow.feeAmount == null || flow.feeAmount === "";
502
+ }
503
+ function formatFee(amount) {
504
+ if (amount == null || amount === "") return "";
505
+ const n = Number(amount);
506
+ if (Number.isNaN(n)) return "";
507
+ return Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`;
508
+ }
509
+ function feeNote(fee) {
510
+ if (!fee) return "";
511
+ if (!fee.requiresPayment || !fee.amount) return "Bulk billed \u2014 no cost to you";
512
+ const text = formatFee(fee.amount);
513
+ return text ? `${text} \u2014 held on your card, charged only if what you asked for is issued` : "";
514
+ }
515
+ function feeRowNote(fee) {
516
+ if (!fee) return "";
517
+ if (!fee.requiresPayment || !fee.amount) return "Bulk billed \u2014 no cost to you";
518
+ return formatFee(fee.amount);
519
+ }
520
+ function gateToExit(gate) {
521
+ if (!gate) return null;
522
+ if (gate.verdict === "escalate") return { kind: "escalate", message: gate.message };
523
+ return { kind: "block", ruleId: gate.ruleId, message: gate.message };
524
+ }
525
+ function errorMessage(err, fallback) {
526
+ if (err instanceof PatientOSApiError && (err.code === "missing_claim" || err.code === "invalid_claim" || err.code === "claim_mismatch")) {
527
+ return fallback;
528
+ }
529
+ if (isLocalMissingClaim(err)) return fallback;
530
+ if (err instanceof PatientOSApiError) return err.message || fallback;
531
+ if (err instanceof Error && err.message) return err.message;
532
+ return fallback;
533
+ }
534
+ function errorCode(err) {
535
+ return err instanceof PatientOSApiError ? err.code : null;
536
+ }
537
+ function isSessionExpired(err) {
538
+ const code = errorCode(err);
539
+ return code === "missing_claim" || code === "invalid_claim" || isLocalMissingClaim(err);
540
+ }
541
+ function isLocalMissingClaim(err) {
542
+ return err instanceof Error && err.message.startsWith("No active session \u2014");
543
+ }
544
+ function isClaimRejected(err) {
545
+ return isSessionExpired(err) || errorCode(err) === "claim_mismatch";
546
+ }
547
+ function observeSessionExpiry(c, onExpired) {
548
+ return new Proxy(c, {
549
+ get(target, property, receiver) {
550
+ const value = Reflect.get(target, property, receiver);
551
+ if (typeof value !== "function") return value;
552
+ return (...args) => {
553
+ try {
554
+ const result = value.apply(target, args);
555
+ if (result && typeof result.then === "function") {
556
+ return Promise.resolve(result).catch((err) => {
557
+ if (isClaimRejected(err)) onExpired();
558
+ throw err;
559
+ });
560
+ }
561
+ return result;
562
+ } catch (err) {
563
+ if (isClaimRejected(err)) onExpired();
564
+ throw err;
565
+ }
566
+ };
567
+ }
568
+ });
569
+ }
570
+
571
+ // src/slot-picker.tsx
572
+ import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
573
+ var SLOT_TAKEN_CODES = ["slot_taken", "slot_unavailable", "slot_full", "hold_conflict"];
574
+ function isSlotTakenError(err) {
575
+ const code = errorCode(err);
576
+ return code !== null && SLOT_TAKEN_CODES.includes(code);
577
+ }
578
+ function groupByTime(slots) {
579
+ const byStart = /* @__PURE__ */ new Map();
580
+ for (const s of slots) {
581
+ const existing = byStart.get(s.start);
582
+ if (existing) existing.count += 1;
583
+ else byStart.set(s.start, { start: s.start, practitionerId: s.practitionerId, count: 1 });
584
+ }
585
+ return [...byStart.values()].sort((a, b) => a.start.localeCompare(b.start));
586
+ }
587
+ function dayKeyOf(iso, tz) {
588
+ return new Intl.DateTimeFormat("en-CA", {
589
+ timeZone: tz,
590
+ year: "numeric",
591
+ month: "2-digit",
592
+ day: "2-digit"
593
+ }).format(new Date(iso));
594
+ }
595
+ function formatSlotTime(iso, tz) {
596
+ return new Intl.DateTimeFormat("en-AU", {
597
+ timeZone: tz,
598
+ hour: "2-digit",
599
+ minute: "2-digit",
600
+ hour12: false
601
+ }).format(new Date(iso));
602
+ }
603
+ function zoneAbbr(iso, tz) {
604
+ const abbr = new Intl.DateTimeFormat("en-AU", { timeZone: tz, timeZoneName: "short" }).formatToParts(new Date(iso)).find((p) => p.type === "timeZoneName")?.value;
605
+ return abbr && !/^(GMT|UTC)/.test(abbr) ? abbr : null;
606
+ }
607
+ function zoneLabel(iso, tz) {
608
+ const city = (tz.split("/").pop() ?? tz).replace(/_/g, " ");
609
+ const abbr = zoneAbbr(iso, tz);
610
+ return abbr ? `${city} time (${abbr})` : `${city} time`;
611
+ }
612
+ function groupByDay(slots, tz) {
613
+ const byDay = /* @__PURE__ */ new Map();
614
+ for (const slot of slots) {
615
+ const key = dayKeyOf(slot.start, tz);
616
+ const existing = byDay.get(key);
617
+ if (existing) {
618
+ existing.slots.push(slot);
619
+ continue;
620
+ }
621
+ byDay.set(key, {
622
+ key,
623
+ tabLabel: new Intl.DateTimeFormat("en-AU", {
624
+ timeZone: tz,
625
+ weekday: "short",
626
+ day: "numeric"
627
+ }).format(new Date(slot.start)),
628
+ heading: new Intl.DateTimeFormat("en-AU", {
629
+ timeZone: tz,
630
+ weekday: "long",
631
+ day: "numeric",
632
+ month: "long"
633
+ }).format(new Date(slot.start)),
634
+ zone: zoneLabel(slot.start, tz),
635
+ slots: [slot]
636
+ });
637
+ }
638
+ return [...byDay.values()].sort((a, b) => a.key.localeCompare(b.key));
639
+ }
640
+ function heldStarts(holds, now = Date.now()) {
641
+ const out = /* @__PURE__ */ new Set();
642
+ for (const h of holds) {
643
+ if (Date.parse(h.expiresAt) <= now) continue;
644
+ out.add(h.slotStart);
645
+ }
646
+ return out;
647
+ }
648
+ function SlotBoard({
649
+ slots,
650
+ tz,
651
+ held,
652
+ selected,
653
+ onSelect,
654
+ mode,
655
+ className
656
+ }) {
657
+ const [dayKey, setDayKey] = React3.useState(null);
658
+ const days = React3.useMemo(() => groupByDay(slots, tz), [slots, tz]);
659
+ const activeDay = days.find((d) => d.key === dayKey) ?? days[0] ?? null;
660
+ if (!activeDay) return /* @__PURE__ */ jsx3(Fragment, {});
661
+ return /* @__PURE__ */ jsxs3("div", { className: ["sk-slots", className].filter(Boolean).join(" "), "data-sk-slot-mode": mode, children: [
662
+ /* @__PURE__ */ jsx3("div", { className: "sk-slots__days", role: "group", "aria-label": "Choose a day", children: days.map((day) => {
663
+ const isActive = day.key === activeDay.key;
664
+ return /* @__PURE__ */ jsx3(
665
+ "button",
666
+ {
667
+ type: "button",
668
+ className: ["sk-slots__day", isActive ? "sk-slots__day--active" : ""].filter(Boolean).join(" "),
669
+ "aria-pressed": isActive,
670
+ onClick: () => {
671
+ setDayKey(day.key);
672
+ onSelect(null);
673
+ },
674
+ children: day.tabLabel
675
+ },
676
+ day.key
677
+ );
678
+ }) }),
679
+ /* @__PURE__ */ jsxs3("p", { className: "sk-slots__heading", children: [
680
+ activeDay.heading,
681
+ " ",
682
+ /* @__PURE__ */ jsxs3("span", { className: "sk-slots__heading-tz", children: [
683
+ "\xB7 ",
684
+ activeDay.zone
685
+ ] })
686
+ ] }),
687
+ /* @__PURE__ */ jsx3(
688
+ "div",
689
+ {
690
+ className: "sk-slots__grid",
691
+ role: "group",
692
+ "aria-label": `Times on ${activeDay.heading}, ${activeDay.zone}`,
693
+ children: activeDay.slots.map((slot) => {
694
+ const isSelected = selected?.start === slot.start;
695
+ const isHeld = (held?.has(slot.start) ?? false) && slot.count === 1;
696
+ return /* @__PURE__ */ jsxs3(
697
+ "button",
698
+ {
699
+ type: "button",
700
+ className: [
701
+ "sk-slots__time",
702
+ isSelected ? "sk-slots__time--selected" : "",
703
+ isHeld ? "sk-slots__time--held" : ""
704
+ ].filter(Boolean).join(" "),
705
+ "aria-pressed": isSelected,
706
+ onClick: () => onSelect(slot),
707
+ children: [
708
+ /* @__PURE__ */ jsx3("span", { className: "sk-slots__time-label", children: formatSlotTime(slot.start, tz) }),
709
+ isHeld && /* @__PURE__ */ jsx3("span", { className: "sk-slots__time-note", children: "Being booked" }),
710
+ !isHeld && slot.count === 1 && /* @__PURE__ */ jsx3("span", { className: "sk-slots__time-note", children: "1 left" })
711
+ ]
712
+ },
713
+ slot.start
714
+ );
715
+ })
716
+ }
717
+ )
718
+ ] });
719
+ }
720
+ function SlotPicker({
721
+ client: client2,
722
+ appointmentTypeId,
723
+ mode,
724
+ onConfirm,
725
+ confirmLabel
726
+ }) {
727
+ const [slots, setSlots] = React3.useState(null);
728
+ const [tz, setTz] = React3.useState("Australia/Sydney");
729
+ const [held, setHeld] = React3.useState(() => /* @__PURE__ */ new Set());
730
+ const [loadError, setLoadError] = React3.useState(null);
731
+ const [bookError, setBookError] = React3.useState(null);
732
+ const [selected, setSelected] = React3.useState(null);
733
+ const [booking, setBooking] = React3.useState(false);
734
+ const aliveRef = React3.useRef(true);
735
+ const load = React3.useCallback(async () => {
736
+ setLoadError(null);
737
+ try {
738
+ const availability = await client2.getAvailability({ appointmentTypeId });
739
+ if (!aliveRef.current) return;
740
+ const grouped = groupByTime(availability.slots);
741
+ setTz(availability.timezone);
742
+ setSlots(grouped);
743
+ setSelected((prev) => prev && grouped.some((s) => s.start === prev.start) ? prev : null);
744
+ } catch (e) {
745
+ if (!aliveRef.current) return;
746
+ setLoadError(errorMessage(e, "We could not load available times."));
747
+ }
748
+ }, [client2, appointmentTypeId]);
749
+ React3.useEffect(() => {
750
+ aliveRef.current = true;
751
+ void load();
752
+ return () => {
753
+ aliveRef.current = false;
754
+ };
755
+ }, [load]);
756
+ React3.useEffect(() => {
757
+ let stop = null;
758
+ try {
759
+ stop = client2.subscribeHolds((rows) => {
760
+ if (aliveRef.current) setHeld(heldStarts(rows));
761
+ });
762
+ } catch {
763
+ }
764
+ return () => stop?.();
765
+ }, [client2]);
766
+ async function handleConfirm() {
767
+ if (!selected || booking) return;
768
+ setBooking(true);
769
+ setBookError(null);
770
+ try {
771
+ await onConfirm(selected, tz);
772
+ } catch (e) {
773
+ if (isSlotTakenError(e)) {
774
+ setBookError("That time just filled up. Please choose another.");
775
+ setSelected(null);
776
+ await load();
777
+ } else {
778
+ setBookError(errorMessage(e, "We could not book that time. Please try again."));
779
+ }
780
+ } finally {
781
+ if (aliveRef.current) setBooking(false);
782
+ }
783
+ }
784
+ if (loadError) {
785
+ return /* @__PURE__ */ jsxs3("div", { className: "sk-slots", children: [
786
+ /* @__PURE__ */ jsx3(FlowError, { children: loadError }),
787
+ /* @__PURE__ */ jsx3(FlowButton, { type: "button", variant: "secondary", onClick: () => void load(), children: "Try again" })
788
+ ] });
789
+ }
790
+ if (slots === null) return /* @__PURE__ */ jsx3(FlowLoading, { message: "Loading available times\u2026" });
791
+ if (slots.length === 0) {
792
+ return /* @__PURE__ */ jsxs3("div", { className: "sk-slots sk-slots--empty", children: [
793
+ /* @__PURE__ */ jsx3("p", { className: "sk-slots__empty-title", children: "No times available right now" }),
794
+ /* @__PURE__ */ jsx3("p", { className: "sk-slots__empty-body", children: "Every time is either booked or outside the booking window." }),
795
+ /* @__PURE__ */ jsx3("div", { className: "sk-slots__empty-actions", children: /* @__PURE__ */ jsx3(FlowButton, { type: "button", variant: "secondary", onClick: () => void load(), children: "Check for times again" }) })
796
+ ] });
797
+ }
798
+ const timeText = selected ? formatSlotTime(selected.start, tz) : "";
799
+ const label = selected ? confirmLabel?.(timeText) ?? (mode === "request" ? `Book ${timeText}` : `Confirm ${timeText}`) : "Select a time";
800
+ return /* @__PURE__ */ jsxs3(Fragment, { children: [
801
+ /* @__PURE__ */ jsx3(SlotBoard, { slots, tz, held, selected, onSelect: setSelected, mode }),
802
+ /* @__PURE__ */ jsx3(FlowError, { children: bookError }),
803
+ /* @__PURE__ */ jsx3("div", { className: "sk-slots__confirm", children: /* @__PURE__ */ jsx3(
804
+ FlowButton,
805
+ {
806
+ type: "button",
807
+ variant: "primary",
808
+ busy: booking,
809
+ disabled: !selected,
810
+ onClick: () => void handleConfirm(),
811
+ children: booking ? "Booking\u2026" : label
812
+ }
813
+ ) })
814
+ ] });
815
+ }
816
+
817
+ // src/portal-booking.client.tsx
818
+ import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
819
+ function rescheduleContextTypeId(ctx) {
820
+ return ctx.appointmentTypeId ?? ctx.appointment?.appointmentType?.id ?? null;
821
+ }
822
+ var BOOKING_TYPES_URL = "/portal/api/booking/types";
823
+ var AVAILABILITY_URL = "/portal/api/booking/availability";
824
+ var HOLDS_URL = "/portal/api/booking/holds";
825
+ var BOOK_URL = "/portal/api/booking/book";
826
+ var APPOINTMENTS_URL = "/portal/api/appointments";
827
+ var PORTAL_BOOKING_TYPES_URL = BOOKING_TYPES_URL;
828
+ function portalRescheduleContextUrl(id) {
829
+ return `${APPOINTMENTS_URL}/${encodeURIComponent(id)}/reschedule-context`;
830
+ }
831
+ var SLOT_TAKEN_COPY = "That time just filled up. Please choose another.";
832
+ var BOOK_FAILED_COPY = "We couldn't book that time just now. Please try again.";
833
+ var AVAILABILITY_FAILED_COPY = "We could not load available times.";
834
+ var RESCHEDULE_CUTOFF_COPY = "It's too close to your appointment to change it online. Please call the clinic and we'll sort it out.";
835
+ var RESCHEDULE_FAILED_COPY = "We couldn't move that appointment just now. Please try again.";
836
+ function readPortalBookingTypes(body) {
837
+ const b = body ?? {};
838
+ return { types: Array.isArray(b.types) ? b.types : [] };
839
+ }
840
+ function readPortalAvailability(body) {
841
+ const b = body ?? {};
842
+ const slots = Array.isArray(b.slots) ? b.slots.filter((s) => typeof s?.start === "string") : [];
843
+ const timezone = typeof b.timezone === "string" && b.timezone ? b.timezone : slots.find((s) => typeof s.timezone === "string" && s.timezone)?.timezone ?? "Australia/Sydney";
844
+ return { slots, timezone };
845
+ }
846
+ function portalTimeSlots(slots) {
847
+ const byStart = /* @__PURE__ */ new Map();
848
+ for (const s of slots) {
849
+ const existing = byStart.get(s.start);
850
+ if (existing) existing.count += 1;
851
+ else byStart.set(s.start, { start: s.start, practitionerId: s.practitionerId ?? "", count: 1 });
852
+ }
853
+ return [...byStart.values()].sort((a, b) => a.start.localeCompare(b.start));
854
+ }
855
+ function portalSlotAt(slots, start) {
856
+ if (!start) return null;
857
+ return slots.find((s) => s.start === start) ?? null;
858
+ }
859
+ function portalAvailabilityWindow(now = Date.now(), days = 14) {
860
+ return {
861
+ from: toYmd(now),
862
+ to: toYmd(now + days * 24 * 60 * 60 * 1e3)
863
+ };
864
+ }
865
+ function toYmd(ms) {
866
+ return new Date(ms).toISOString().slice(0, 10);
867
+ }
868
+ function portalAvailabilityUrl(typeId, window2 = portalAvailabilityWindow()) {
869
+ const q = new URLSearchParams({ typeId, from: window2.from, to: window2.to });
870
+ return `${AVAILABILITY_URL}?${q.toString()}`;
871
+ }
872
+ async function loadPortalBookingTypes(fetchImpl) {
873
+ const res = await portalGet(BOOKING_TYPES_URL, fetchImpl);
874
+ return res.ok ? { ok: true, data: readPortalBookingTypes(res.data) } : res;
875
+ }
876
+ async function loadPortalAvailability(typeId, fetchImpl, window2) {
877
+ const res = await portalGet(portalAvailabilityUrl(typeId, window2), fetchImpl);
878
+ return res.ok ? { ok: true, data: readPortalAvailability(res.data) } : res;
879
+ }
880
+ async function createPortalHold(input, fetchImpl) {
881
+ const res = await portalSend(
882
+ HOLDS_URL,
883
+ "POST",
884
+ {
885
+ // Wire names are the server's (PAT-789 canonical contract): `slotStart`, with
886
+ // `typeId` accepted as the documented alias of `appointmentTypeId`.
887
+ typeId: input.typeId,
888
+ slotStart: input.start,
889
+ practitionerId: input.practitionerId || void 0
890
+ },
891
+ fetchImpl
892
+ );
893
+ if (!res.ok || typeof res.data?.holdId !== "string" || res.data.holdId === "") return null;
894
+ return { holdId: res.data.holdId, expiresAt: res.data.expiresAt ?? null };
895
+ }
896
+ async function releasePortalHold(holdId, fetchImpl) {
897
+ const res = await portalDelete(
898
+ `${HOLDS_URL}/${encodeURIComponent(holdId)}`,
899
+ fetchImpl
900
+ );
901
+ return { ok: res.ok };
902
+ }
903
+ async function bookPortalAppointment(input, fetchImpl) {
904
+ const res = await portalSend(
905
+ BOOK_URL,
906
+ "POST",
907
+ {
908
+ typeId: input.typeId,
909
+ slotStart: input.start,
910
+ practitionerId: input.practitionerId || void 0,
911
+ holdId: input.holdId || void 0
912
+ },
913
+ fetchImpl
914
+ );
915
+ if (!res.ok) {
916
+ return {
917
+ ok: false,
918
+ slotTaken: res.status === 409,
919
+ error: res.status === 409 ? SLOT_TAKEN_COPY : BOOK_FAILED_COPY
920
+ };
921
+ }
922
+ return {
923
+ ok: true,
924
+ appointmentId: typeof res.data?.appointmentId === "string" ? res.data.appointmentId : null
925
+ };
926
+ }
927
+ async function loadPortalRescheduleContext(id, fetchImpl) {
928
+ const res = await portalGet(portalRescheduleContextUrl(id), fetchImpl);
929
+ if (!res.ok) {
930
+ const cutoff = res.status === 409;
931
+ return { ok: false, cutoff, error: cutoff ? RESCHEDULE_CUTOFF_COPY : RESCHEDULE_FAILED_COPY };
932
+ }
933
+ return { ok: true, context: res.data ?? {} };
934
+ }
935
+ async function reschedulePortalAppointmentAndReload(id, input, fetchImpl) {
936
+ const res = await portalSend(
937
+ `${APPOINTMENTS_URL}/${encodeURIComponent(id)}/reschedule`,
938
+ "POST",
939
+ { slotStart: input.start, practitionerId: input.practitionerId || void 0 },
940
+ fetchImpl
941
+ );
942
+ if (!res.ok) {
943
+ const cutoff = res.status === 409 && res.error === "cutoff";
944
+ const slotTaken = res.status === 409 && !cutoff;
945
+ return {
946
+ ok: false,
947
+ cutoff,
948
+ slotTaken,
949
+ error: cutoff ? RESCHEDULE_CUTOFF_COPY : slotTaken ? SLOT_TAKEN_COPY : RESCHEDULE_FAILED_COPY
950
+ };
951
+ }
952
+ const next = await portalGet(APPOINTMENTS_URL, fetchImpl);
953
+ const body = (next.ok ? next.data : null) ?? { upcoming: [], past: [] };
954
+ return {
955
+ ok: true,
956
+ data: {
957
+ upcoming: Array.isArray(body.upcoming) ? body.upcoming : [],
958
+ past: Array.isArray(body.past) ? body.past : []
959
+ }
960
+ };
961
+ }
962
+ function PortalSlotSummary({
963
+ start,
964
+ slot,
965
+ tz,
966
+ typeLabel
967
+ }) {
968
+ const meta = [slot?.practitionerName, slot?.locationName].filter(Boolean).join(" \xB7 ");
969
+ return /* @__PURE__ */ jsxs4("dl", { className: "sk-portal-account__fields", children: [
970
+ typeLabel ? /* @__PURE__ */ jsxs4("div", { className: "sk-portal-account__field", children: [
971
+ /* @__PURE__ */ jsx4("dt", { className: "sk-portal-account__field-label", children: "Appointment" }),
972
+ /* @__PURE__ */ jsx4("dd", { className: "sk-portal-account__field-value", children: typeLabel })
973
+ ] }) : null,
974
+ /* @__PURE__ */ jsxs4("div", { className: "sk-portal-account__field", children: [
975
+ /* @__PURE__ */ jsx4("dt", { className: "sk-portal-account__field-label", children: "Time" }),
976
+ /* @__PURE__ */ jsx4("dd", { className: "sk-portal-account__field-value", children: formatPortalDateTime(start, tz) ?? formatSlotTime(start, tz) })
977
+ ] }),
978
+ meta ? /* @__PURE__ */ jsxs4("div", { className: "sk-portal-account__field", children: [
979
+ /* @__PURE__ */ jsx4("dt", { className: "sk-portal-account__field-label", children: "With" }),
980
+ /* @__PURE__ */ jsx4("dd", { className: "sk-portal-account__field-value", children: meta })
981
+ ] }) : null
982
+ ] });
983
+ }
984
+ function ErrorNote({ message }) {
985
+ if (!message) return null;
986
+ return /* @__PURE__ */ jsx4("p", { className: "sk-portal-error", role: "alert", children: message });
987
+ }
988
+ function NoTimes() {
989
+ return /* @__PURE__ */ jsxs4("div", { className: "sk-portal-account__note-card", children: [
990
+ /* @__PURE__ */ jsx4("p", { className: "sk-portal-account__confirm-title", children: "No times available right now" }),
991
+ /* @__PURE__ */ jsx4("p", { className: "sk-portal-account__empty", children: "Every time is either booked or outside the booking window. Please try again shortly, or call the clinic." })
992
+ ] });
993
+ }
994
+ function PortalRescheduleInline({
995
+ appointment,
996
+ fetchImpl,
997
+ onRescheduled,
998
+ onClose,
999
+ initialUi
1000
+ }) {
1001
+ const [context, setContext] = React4.useState(
1002
+ initialUi?.context ?? null
1003
+ );
1004
+ const [slots, setSlots] = React4.useState(
1005
+ initialUi?.slots ?? null
1006
+ );
1007
+ const [tz, setTz] = React4.useState(initialUi?.tz ?? appointment.timezone ?? "Australia/Sydney");
1008
+ const [selected, setSelected] = React4.useState(initialUi?.selected ?? null);
1009
+ const [confirming, setConfirming] = React4.useState(initialUi?.confirming ?? false);
1010
+ const [error, setError] = React4.useState(initialUi?.error ?? null);
1011
+ const [blocked, setBlocked] = React4.useState(initialUi?.blocked ?? null);
1012
+ const [busy, setBusy] = React4.useState(false);
1013
+ const seeded = initialUi !== void 0;
1014
+ const timeSlots = React4.useMemo(() => slots ? portalTimeSlots(slots) : [], [slots]);
1015
+ React4.useEffect(() => {
1016
+ if (seeded) return;
1017
+ let live = true;
1018
+ void (async () => {
1019
+ const res = await loadPortalRescheduleContext(appointment.id, fetchImpl);
1020
+ if (!live) return;
1021
+ if (!res.ok) {
1022
+ setBlocked(res.error);
1023
+ setSlots([]);
1024
+ return;
1025
+ }
1026
+ setContext(res.context);
1027
+ const inline = res.context.slots;
1028
+ if (Array.isArray(inline)) {
1029
+ const read = readPortalAvailability({ slots: inline, timezone: res.context.timezone });
1030
+ setSlots(read.slots);
1031
+ setTz(read.timezone);
1032
+ return;
1033
+ }
1034
+ const typeId = rescheduleContextTypeId(res.context);
1035
+ if (!typeId) {
1036
+ setSlots([]);
1037
+ return;
1038
+ }
1039
+ const avail = await loadPortalAvailability(typeId, fetchImpl);
1040
+ if (!live) return;
1041
+ if (!avail.ok) {
1042
+ setSlots([]);
1043
+ setError(AVAILABILITY_FAILED_COPY);
1044
+ return;
1045
+ }
1046
+ setSlots(avail.data.slots);
1047
+ setTz(avail.data.timezone);
1048
+ })();
1049
+ return () => {
1050
+ live = false;
1051
+ };
1052
+ }, [appointment.id, fetchImpl, seeded]);
1053
+ async function refetch() {
1054
+ const typeId = context ? rescheduleContextTypeId(context) : null;
1055
+ if (!typeId) return;
1056
+ const avail = await loadPortalAvailability(typeId, fetchImpl);
1057
+ if (!avail.ok) return;
1058
+ setSlots(avail.data.slots);
1059
+ setTz(avail.data.timezone);
1060
+ }
1061
+ async function move() {
1062
+ if (!selected || busy) return;
1063
+ setBusy(true);
1064
+ setError(null);
1065
+ const res = await reschedulePortalAppointmentAndReload(
1066
+ appointment.id,
1067
+ { start: selected.start, practitionerId: selected.practitionerId },
1068
+ fetchImpl
1069
+ );
1070
+ setBusy(false);
1071
+ if (res.ok) {
1072
+ onRescheduled(res.data);
1073
+ return;
1074
+ }
1075
+ if (res.cutoff) {
1076
+ setBlocked(res.error);
1077
+ setConfirming(false);
1078
+ return;
1079
+ }
1080
+ setError(res.error);
1081
+ if (res.slotTaken) {
1082
+ setSelected(null);
1083
+ setConfirming(false);
1084
+ await refetch();
1085
+ }
1086
+ }
1087
+ const typeLabel = context?.typeLabel ?? appointment.title ?? null;
1088
+ return /* @__PURE__ */ jsxs4("div", { className: "sk-portal-account__confirm sk-portal-account__confirm--neutral", children: [
1089
+ /* @__PURE__ */ jsx4("p", { className: "sk-portal-account__confirm-title", children: "Move this appointment" }),
1090
+ /* @__PURE__ */ jsxs4("p", { className: "sk-portal-account__item-meta", children: [
1091
+ "Currently ",
1092
+ formatPortalDateTime(appointment.start, appointment.timezone) ?? "unscheduled"
1093
+ ] }),
1094
+ blocked ? /* @__PURE__ */ jsx4("p", { className: "sk-portal-account__note", role: "note", children: blocked }) : slots === null ? /* @__PURE__ */ jsx4("p", { className: "sk-portal-account__placeholder", role: "status", children: "Loading available times\u2026" }) : confirming && selected ? /* @__PURE__ */ jsxs4(Fragment2, { children: [
1095
+ /* @__PURE__ */ jsx4(
1096
+ PortalSlotSummary,
1097
+ {
1098
+ start: selected.start,
1099
+ slot: portalSlotAt(slots, selected.start),
1100
+ tz,
1101
+ typeLabel
1102
+ }
1103
+ ),
1104
+ /* @__PURE__ */ jsx4(ErrorNote, { message: error }),
1105
+ /* @__PURE__ */ jsxs4("div", { className: "sk-portal-account__confirm-actions", children: [
1106
+ /* @__PURE__ */ jsx4(
1107
+ "button",
1108
+ {
1109
+ type: "button",
1110
+ className: "sk-portal-button",
1111
+ onClick: () => void move(),
1112
+ disabled: busy,
1113
+ children: busy ? "Moving\u2026" : "Move appointment"
1114
+ }
1115
+ ),
1116
+ /* @__PURE__ */ jsx4(
1117
+ "button",
1118
+ {
1119
+ type: "button",
1120
+ className: "sk-portal-button sk-portal-button--ghost",
1121
+ onClick: () => setConfirming(false),
1122
+ disabled: busy,
1123
+ children: "Choose another time"
1124
+ }
1125
+ )
1126
+ ] })
1127
+ ] }) : timeSlots.length === 0 ? /* @__PURE__ */ jsx4(NoTimes, {}) : /* @__PURE__ */ jsxs4(Fragment2, { children: [
1128
+ /* @__PURE__ */ jsx4(ErrorNote, { message: error }),
1129
+ /* @__PURE__ */ jsx4(
1130
+ SlotBoard,
1131
+ {
1132
+ slots: timeSlots,
1133
+ tz,
1134
+ selected,
1135
+ onSelect: setSelected,
1136
+ mode: "portal-reschedule"
1137
+ }
1138
+ ),
1139
+ /* @__PURE__ */ jsx4("div", { className: "sk-portal-account__confirm-actions", children: /* @__PURE__ */ jsx4(
1140
+ "button",
1141
+ {
1142
+ type: "button",
1143
+ className: "sk-portal-button",
1144
+ onClick: () => setConfirming(true),
1145
+ disabled: !selected,
1146
+ children: selected ? `Continue with ${formatSlotTime(selected.start, tz)}` : "Select a time"
1147
+ }
1148
+ ) })
1149
+ ] }),
1150
+ /* @__PURE__ */ jsx4("div", { className: "sk-portal-account__confirm-actions", children: /* @__PURE__ */ jsx4(
1151
+ "button",
1152
+ {
1153
+ type: "button",
1154
+ className: "sk-portal-button sk-portal-button--ghost",
1155
+ onClick: onClose,
1156
+ disabled: busy,
1157
+ children: "Keep the current time"
1158
+ }
1159
+ ) })
1160
+ ] });
1161
+ }
1162
+
1163
+ export {
1164
+ FlowRailContext,
1165
+ FlowShell,
1166
+ FlowButton,
1167
+ SummarisedFieldErrors,
1168
+ FlowError,
1169
+ FlowFieldError,
1170
+ describedBy,
1171
+ invalidFields,
1172
+ FlowErrorSummary,
1173
+ focusField,
1174
+ FlowLoading,
1175
+ PatientOSApiError,
1176
+ patientos,
1177
+ persistClaimToken,
1178
+ adoptClaimToken,
1179
+ resetSession,
1180
+ saveFlowRecord,
1181
+ loadFlowRecord,
1182
+ once,
1183
+ findServiceByKey,
1184
+ pickDeliveryOption,
1185
+ findDeliveryOption,
1186
+ effectiveFee,
1187
+ flowIsFree,
1188
+ formatFee,
1189
+ feeNote,
1190
+ feeRowNote,
1191
+ gateToExit,
1192
+ errorMessage,
1193
+ errorCode,
1194
+ isSessionExpired,
1195
+ isClaimRejected,
1196
+ observeSessionExpiry,
1197
+ isSlotTakenError,
1198
+ formatSlotTime,
1199
+ zoneAbbr,
1200
+ SlotPicker,
1201
+ PORTAL_REQUEST_INIT,
1202
+ portalGet,
1203
+ portalSend,
1204
+ portalDelete,
1205
+ hasPortalIdentity,
1206
+ readPortalSignedInHint,
1207
+ writePortalSignedInHint,
1208
+ requestPortalSignInLink,
1209
+ signOutOfPortal,
1210
+ currentPagePath,
1211
+ formatPortalDate,
1212
+ formatPortalDateTime,
1213
+ formatPortalTime,
1214
+ formatPortalMoney,
1215
+ PortalSignInRamp,
1216
+ PortalLinkSentNotice,
1217
+ rescheduleContextTypeId,
1218
+ PORTAL_BOOKING_TYPES_URL,
1219
+ portalRescheduleContextUrl,
1220
+ SLOT_TAKEN_COPY,
1221
+ BOOK_FAILED_COPY,
1222
+ AVAILABILITY_FAILED_COPY,
1223
+ RESCHEDULE_CUTOFF_COPY,
1224
+ RESCHEDULE_FAILED_COPY,
1225
+ readPortalBookingTypes,
1226
+ readPortalAvailability,
1227
+ portalTimeSlots,
1228
+ portalSlotAt,
1229
+ portalAvailabilityWindow,
1230
+ portalAvailabilityUrl,
1231
+ loadPortalBookingTypes,
1232
+ loadPortalAvailability,
1233
+ createPortalHold,
1234
+ releasePortalHold,
1235
+ bookPortalAppointment,
1236
+ loadPortalRescheduleContext,
1237
+ reschedulePortalAppointmentAndReload,
1238
+ PortalSlotSummary,
1239
+ PortalRescheduleInline
1240
+ };