@patientos/website-kit 0.1.0 → 0.2.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.
@@ -25,6 +25,7 @@ import {
25
25
  SlotPicker,
26
26
  SummarisedFieldErrors,
27
27
  adoptClaimToken,
28
+ configurePortalApiOrigin,
28
29
  currentPagePath,
29
30
  describedBy,
30
31
  effectiveFee,
@@ -54,6 +55,7 @@ import {
54
55
  patientos,
55
56
  persistClaimToken,
56
57
  pickDeliveryOption,
58
+ portalApiUrl,
57
59
  portalDelete,
58
60
  portalGet,
59
61
  portalSend,
@@ -64,7 +66,7 @@ import {
64
66
  signOutOfPortal,
65
67
  writePortalSignedInHint,
66
68
  zoneAbbr
67
- } from "./chunk-G3U6EXJM.js";
69
+ } from "./chunk-TMW4R76E.js";
68
70
  import {
69
71
  __export
70
72
  } from "./chunk-MLKGABMK.js";
@@ -1492,13 +1494,14 @@ function portalSurfaceHref(surface, portalUrl) {
1492
1494
  function PortalPanel({
1493
1495
  surface,
1494
1496
  portalUrl,
1497
+ portalApiOrigin,
1495
1498
  className
1496
1499
  }) {
1497
1500
  return /* @__PURE__ */ jsxs4(
1498
1501
  Island,
1499
1502
  {
1500
1503
  name: "portal-panel",
1501
- props: { surface, portalUrl },
1504
+ props: { surface, portalUrl, portalApiOrigin },
1502
1505
  className: ["sk-portal-panel", className].filter(Boolean).join(" "),
1503
1506
  children: [
1504
1507
  /* @__PURE__ */ jsx5("h2", { className: "sk-portal-panel__heading", children: PORTAL_SURFACE_LABEL[surface] ?? "Your portal" }),
@@ -4548,7 +4551,7 @@ async function loadPortalPanel(surface, fetchImpl) {
4548
4551
  let body;
4549
4552
  try {
4550
4553
  const res = await fetchImpl(
4551
- `${SUMMARY_URL}?surface=${encodeURIComponent(surface)}`,
4554
+ portalApiUrl(`${SUMMARY_URL}?surface=${encodeURIComponent(surface)}`),
4552
4555
  PORTAL_REQUEST_INIT
4553
4556
  );
4554
4557
  body = await res.json();
@@ -4581,9 +4584,11 @@ function itemMeta(item) {
4581
4584
  function PortalPanelClient({
4582
4585
  surface,
4583
4586
  portalUrl,
4587
+ portalApiOrigin,
4584
4588
  initialState,
4585
4589
  fetchImpl
4586
4590
  }) {
4591
+ configurePortalApiOrigin(portalApiOrigin);
4587
4592
  const [state, setState] = React16.useState(initialState ?? { kind: "loading" });
4588
4593
  React16.useEffect(() => {
4589
4594
  if (initialState) return;
@@ -10260,6 +10265,7 @@ function PortalAccount({
10260
10265
  bookHref,
10261
10266
  appointmentsHref,
10262
10267
  funnelHref,
10268
+ portalApiOrigin,
10263
10269
  className
10264
10270
  }) {
10265
10271
  const href = portalUrl ?? "/portal";
@@ -10276,6 +10282,7 @@ function PortalAccount({
10276
10282
  portalUrl,
10277
10283
  bookHref,
10278
10284
  appointmentsHref,
10285
+ portalApiOrigin,
10279
10286
  ...isBook ? { funnelHref, services, publicApi, clinicPhone } : {}
10280
10287
  },
10281
10288
  className: ["sk-portal-account", className].filter(Boolean).join(" "),
@@ -10380,8 +10387,11 @@ var VALIDATE_URL = "/api/address/validate";
10380
10387
  async function searchPortalAddresses(query) {
10381
10388
  const q = query.trim();
10382
10389
  if (q.length < 3) return [];
10383
- const res = await fetch(`${AUTOCOMPLETE_URL}?q=${encodeURIComponent(q)}`, {
10384
- credentials: "same-origin",
10390
+ const res = await fetch(portalApiUrl(`${AUTOCOMPLETE_URL}?q=${encodeURIComponent(q)}`), {
10391
+ // `include`, not `same-origin`: on a separately-deployed clinic site this resolves to
10392
+ // the APP's origin, and the endpoint's only door for a patient is the portal session
10393
+ // cookie (address-google.server.ts `hasAddressAccess`). Same-origin it is identical.
10394
+ credentials: "include",
10385
10395
  headers: { accept: "application/json" }
10386
10396
  });
10387
10397
  if (!res.ok) return [];
@@ -10389,9 +10399,9 @@ async function searchPortalAddresses(query) {
10389
10399
  return body.suggestions ?? [];
10390
10400
  }
10391
10401
  async function validatePortalAddress(address) {
10392
- const res = await fetch(VALIDATE_URL, {
10402
+ const res = await fetch(portalApiUrl(VALIDATE_URL), {
10393
10403
  method: "POST",
10394
- credentials: "same-origin",
10404
+ credentials: "include",
10395
10405
  headers: { "content-type": "application/json" },
10396
10406
  body: JSON.stringify({ address })
10397
10407
  });
@@ -10852,6 +10862,7 @@ async function savePortalProfileAndReload(patch, fetchImpl) {
10852
10862
  }
10853
10863
  function PortalAccountClient(props) {
10854
10864
  const { surface } = props;
10865
+ configurePortalApiOrigin(props.portalApiOrigin);
10855
10866
  if (surface === "book") return /* @__PURE__ */ jsx26(PortalAccountBookLauncher, { ...props });
10856
10867
  return /* @__PURE__ */ jsx26(PortalAccountSurfaceClient, { ...props, surface });
10857
10868
  }
@@ -6,7 +6,7 @@ import {
6
6
  PortalAccountClient,
7
7
  PortalPanelClient,
8
8
  StoreClient
9
- } from "./chunk-TOPPLOH2.js";
9
+ } from "./chunk-CRCCBM4F.js";
10
10
  import {
11
11
  mountIslandsWith
12
12
  } from "./chunk-THMT43MV.js";
@@ -4,6 +4,44 @@ import * as React4 from "react";
4
4
  // src/portal-shared.client.tsx
5
5
  import * as React from "react";
6
6
  import { jsx, jsxs } from "react/jsx-runtime";
7
+ var apiOrigin = "";
8
+ var apiOriginLocked = false;
9
+ function normalizePortalApiOrigin(raw) {
10
+ let u;
11
+ try {
12
+ u = new URL(raw.trim());
13
+ } catch {
14
+ return null;
15
+ }
16
+ const loopback = u.hostname === "localhost" || u.hostname === "127.0.0.1";
17
+ if (u.protocol !== "https:" && !(u.protocol === "http:" && loopback)) return null;
18
+ if (u.username || u.password || u.search || u.hash) return null;
19
+ if (u.pathname !== "/" && u.pathname !== "") return null;
20
+ return u.origin;
21
+ }
22
+ function configurePortalApiOrigin(raw) {
23
+ const wanted = raw?.trim();
24
+ if (!wanted) return;
25
+ const origin = normalizePortalApiOrigin(wanted);
26
+ if (!origin) {
27
+ console.error("[portal] ignoring a malformed portal API origin", wanted);
28
+ return;
29
+ }
30
+ if (apiOriginLocked) {
31
+ if (origin !== apiOrigin) {
32
+ console.error("[portal] refusing to re-point the portal API origin", {
33
+ configured: apiOrigin,
34
+ rejected: origin
35
+ });
36
+ }
37
+ return;
38
+ }
39
+ apiOrigin = origin;
40
+ apiOriginLocked = true;
41
+ }
42
+ function portalApiUrl(path) {
43
+ return apiOrigin && path.startsWith("/") ? apiOrigin + path : path;
44
+ }
7
45
  var PORTAL_MAGIC_LINK_URL = "/portal/api/magic-link";
8
46
  var PORTAL_SIGN_OUT_URL = "/portal/api/sign-out";
9
47
  var PORTAL_REQUEST_INIT = { credentials: "include", cache: "no-store" };
@@ -30,14 +68,16 @@ async function readPortalResult(res) {
30
68
  }
31
69
  async function portalGet(url, fetchImpl) {
32
70
  try {
33
- return await readPortalResult(await portalFetch(fetchImpl)(url, PORTAL_REQUEST_INIT));
71
+ return await readPortalResult(
72
+ await portalFetch(fetchImpl)(portalApiUrl(url), PORTAL_REQUEST_INIT)
73
+ );
34
74
  } catch {
35
75
  return { ok: false, status: null, error: null };
36
76
  }
37
77
  }
38
78
  async function portalSend(url, method, body, fetchImpl) {
39
79
  try {
40
- const res = await portalFetch(fetchImpl)(url, {
80
+ const res = await portalFetch(fetchImpl)(portalApiUrl(url), {
41
81
  ...PORTAL_REQUEST_INIT,
42
82
  method,
43
83
  headers: { "content-type": "application/json" },
@@ -50,7 +90,10 @@ async function portalSend(url, method, body, fetchImpl) {
50
90
  }
51
91
  async function portalDelete(url, fetchImpl) {
52
92
  try {
53
- const res = await portalFetch(fetchImpl)(url, { ...PORTAL_REQUEST_INIT, method: "DELETE" });
93
+ const res = await portalFetch(fetchImpl)(portalApiUrl(url), {
94
+ ...PORTAL_REQUEST_INIT,
95
+ method: "DELETE"
96
+ });
54
97
  return await readPortalResult(res);
55
98
  } catch {
56
99
  return { ok: false, status: null, error: null };
@@ -89,9 +132,12 @@ async function signOutOfPortal(fetchImpl) {
89
132
  const res = await portalSend(PORTAL_SIGN_OUT_URL, "POST", {}, fetchImpl);
90
133
  return { ok: res.ok };
91
134
  }
135
+ var PORTAL_RETURN_PATH = "/portal/return";
92
136
  function currentPagePath() {
93
137
  if (typeof window === "undefined") return "/";
94
- return window.location.pathname || "/";
138
+ const path = `${window.location.pathname || "/"}${window.location.search}`;
139
+ if (!apiOrigin) return path;
140
+ return `${PORTAL_RETURN_PATH}?to=${encodeURIComponent(window.location.origin + path)}`;
95
141
  }
96
142
  var LOCALE = "en-AU";
97
143
  function parseInstant(iso) {
@@ -1198,6 +1244,8 @@ export {
1198
1244
  formatSlotTime,
1199
1245
  zoneAbbr,
1200
1246
  SlotPicker,
1247
+ configurePortalApiOrigin,
1248
+ portalApiUrl,
1201
1249
  PORTAL_REQUEST_INIT,
1202
1250
  portalGet,
1203
1251
  portalSend,
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ import * as React from 'react';
3
3
  export { ISLAND_ATTR, ISLAND_PROPS_ATTR, Island, IslandProps, IslandRegistry, mountIslandsWith, readIslandProps } from './islands.js';
4
4
  export { ISLANDS, IslandName, mountIslands } from './islands-registry.js';
5
5
  export { B as BookingBlock, a as BookingBlockMarkerProps, b as BookingBlockProps, c as BookingTypeOption, C as Cart, d as CartMarkerProps, e as CartProps, f as CertificateFunnel, g as CertificateFunnelMarkerProps, h as CertificateFunnelProps, i as Checkout, j as CheckoutMarkerProps, k as CheckoutProps, F as FunnelPublicApi, l as FunnelServiceOption, S as Store, m as StoreMarkerProps, n as StoreProps } from './checkout-block-CrO7OxI9.js';
6
- export { P as PortalAccount, a as PortalAccountMarkerProps, b as PortalAccountProps, c as PortalAccountSurface, d as PortalPanel, e as PortalPanelMarkerProps, f as PortalPanelProps, g as PortalSurface } from './portal-account-EhOtLV5u.js';
6
+ export { P as PortalAccount, a as PortalAccountMarkerProps, b as PortalAccountProps, c as PortalAccountSurface, d as PortalPanel, e as PortalPanelMarkerProps, f as PortalPanelProps, g as PortalSurface } from './portal-account-CaK1lqSZ.js';
7
7
  export { PatientSurfaceTheme, patientSurfaceTheme } from './patient-surface-theme.js';
8
8
  export { Rgb, contrastRatio, deriveForeground, parseHexColor, relativeLuminance } from './contrast.js';
9
9
  export { AnswerValue, Answers, ShowWhenCondition, evaluateShowWhen, visibleQuestions } from './show-when.js';
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ISLANDS,
3
3
  mountIslands
4
- } from "./chunk-Q4URDZAK.js";
4
+ } from "./chunk-RUCVZ2C3.js";
5
5
  import {
6
6
  PortalAccount,
7
7
  PortalPanel,
@@ -10,7 +10,7 @@ import {
10
10
  getPublicApi,
11
11
  getServices,
12
12
  getThemeTokens
13
- } from "./chunk-TOPPLOH2.js";
13
+ } from "./chunk-CRCCBM4F.js";
14
14
  import {
15
15
  BPOINT_SCRIPT_ORIGIN,
16
16
  BPOINT_SCRIPT_URL,
@@ -30,7 +30,7 @@ import {
30
30
  mountIslandsWith,
31
31
  readIslandProps
32
32
  } from "./chunk-THMT43MV.js";
33
- import "./chunk-G3U6EXJM.js";
33
+ import "./chunk-TMW4R76E.js";
34
34
  import {
35
35
  contrastRatio,
36
36
  deriveForeground,
@@ -1,8 +1,8 @@
1
1
  import * as React from 'react';
2
2
  import { b as BookingBlockProps, h as CertificateFunnelProps, n as StoreProps, e as CartProps, k as CheckoutProps } from './checkout-block-CrO7OxI9.js';
3
- import { f as PortalPanelProps } from './portal-account-EhOtLV5u.js';
4
- import { F as FetchLike } from './portal-account.client-CTget1-3.js';
5
- export { P as PortalAccountClient } from './portal-account.client-CTget1-3.js';
3
+ import { f as PortalPanelProps } from './portal-account-CaK1lqSZ.js';
4
+ import { F as FetchLike } from './portal-account.client-CqnsCCiC.js';
5
+ export { P as PortalAccountClient } from './portal-account.client-CqnsCCiC.js';
6
6
 
7
7
  declare function BookingBlockClient(props: BookingBlockProps): React.ReactElement;
8
8
 
@@ -57,7 +57,7 @@ type PortalPanelClientProps = PortalPanelProps & {
57
57
  /** Test seam: the fetch used for whoami/summary/magic-link. Defaults to global. */
58
58
  fetchImpl?: FetchLike;
59
59
  };
60
- declare function PortalPanelClient({ surface, portalUrl, initialState, fetchImpl, }: PortalPanelClientProps): React.ReactElement;
60
+ declare function PortalPanelClient({ surface, portalUrl, portalApiOrigin, initialState, fetchImpl, }: PortalPanelClientProps): React.ReactElement;
61
61
 
62
62
  declare function StoreClient({ categoryHandle, title, columns }: StoreProps): React.ReactElement;
63
63
 
@@ -6,11 +6,11 @@ import {
6
6
  PortalAccountClient,
7
7
  PortalPanelClient,
8
8
  StoreClient
9
- } from "./chunk-TOPPLOH2.js";
9
+ } from "./chunk-CRCCBM4F.js";
10
10
  import "./chunk-ZOF22TJA.js";
11
11
  import "./chunk-QDO3SJWR.js";
12
12
  import "./chunk-THMT43MV.js";
13
- import "./chunk-G3U6EXJM.js";
13
+ import "./chunk-TMW4R76E.js";
14
14
  import "./chunk-MLKGABMK.js";
15
15
  export {
16
16
  BookingBlockClient,
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  ISLANDS,
3
3
  mountIslands
4
- } from "./chunk-Q4URDZAK.js";
5
- import "./chunk-TOPPLOH2.js";
4
+ } from "./chunk-RUCVZ2C3.js";
5
+ import "./chunk-CRCCBM4F.js";
6
6
  import "./chunk-ZOF22TJA.js";
7
7
  import "./chunk-QDO3SJWR.js";
8
8
  import "./chunk-THMT43MV.js";
9
- import "./chunk-G3U6EXJM.js";
9
+ import "./chunk-TMW4R76E.js";
10
10
  import "./chunk-MLKGABMK.js";
11
11
  export {
12
12
  ISLANDS,
@@ -11,6 +11,12 @@ type PortalPanelProps = {
11
11
  * `/portal` path on this same origin.
12
12
  */
13
13
  portalUrl?: string;
14
+ /**
15
+ * The APP's origin, when the site is deployed SEPARATELY from it (PAT-908). Omitted ⇒
16
+ * same-origin, which is what a one-origin clinic site wants. See the fuller note on
17
+ * `PortalAccountProps.portalApiOrigin` — the two islands take the same value.
18
+ */
19
+ portalApiOrigin?: string;
14
20
  };
15
21
  /** Marker-only props: the island config plus presentation the page controls. */
16
22
  type PortalPanelMarkerProps = PortalPanelProps & {
@@ -26,7 +32,7 @@ type PortalPanelMarkerProps = PortalPanelProps & {
26
32
  * gets permanently. A `visibility:hidden` / `opacity:0` placeholder would make the
27
33
  * no-JS experience a blank box.
28
34
  */
29
- declare function PortalPanel({ surface, portalUrl, className, }: PortalPanelMarkerProps): React.ReactElement;
35
+ declare function PortalPanel({ surface, portalUrl, portalApiOrigin, className, }: PortalPanelMarkerProps): React.ReactElement;
30
36
 
31
37
  /** A service the launcher can offer — what the build snapshot carries, nothing more. */
32
38
  type LauncherService = {
@@ -88,6 +94,20 @@ type PortalAccountProps = {
88
94
  * patient who picks a service (PAT-899). Omitted ⇒ `/certificates`.
89
95
  */
90
96
  funnelHref?: string;
97
+ /**
98
+ * The APP's origin, when this site is deployed SEPARATELY from it (PAT-908).
99
+ *
100
+ * A one-origin clinic site omits this: the app serves `/portal/api/*` on the same host
101
+ * and every call is a relative path, exactly as before. A site on its own deployment —
102
+ * pocketdoc.com.au on a Worker, the app behind `my.pocketdoc.com.au` — names the app
103
+ * here, and every portal request is made against it with the patient cookie attached.
104
+ *
105
+ * It must be a bare `https://host[:port]` that the clinic has registered in its
106
+ * `web_form` channel `allowedOrigins`, or the app answers 403 with no ACAO. Keep it
107
+ * SAME-SITE with the page (a subdomain of the same registrable domain): a genuinely
108
+ * cross-SITE origin gets no cookie from the browser and every call reads signed-out.
109
+ */
110
+ portalApiOrigin?: string;
91
111
  /** The clinic's services, for the `book` launcher. */
92
112
  services?: LauncherService[];
93
113
  /** How the launcher reaches the public API to price those services. */
@@ -108,6 +128,6 @@ type PortalAccountMarkerProps = PortalAccountProps & {
108
128
  * It shows no patient data because there is none at build time: the page is ONE static
109
129
  * artifact served to every visitor.
110
130
  */
111
- declare function PortalAccount({ surface, portalUrl, bookHref, appointmentsHref, funnelHref, className, }: PortalAccountMarkerProps): React.ReactElement;
131
+ declare function PortalAccount({ surface, portalUrl, bookHref, appointmentsHref, funnelHref, portalApiOrigin, className, }: PortalAccountMarkerProps): React.ReactElement;
112
132
 
113
133
  export { PortalAccount as P, type PortalAccountMarkerProps as a, type PortalAccountProps as b, type PortalAccountSurface as c, PortalPanel as d, type PortalPanelMarkerProps as e, type PortalPanelProps as f, type PortalSurface as g };
@@ -1,5 +1,5 @@
1
1
  import * as React from 'react';
2
- import { b as PortalAccountProps } from './portal-account-EhOtLV5u.js';
2
+ import { b as PortalAccountProps } from './portal-account-CaK1lqSZ.js';
3
3
 
4
4
  /** The injectable fetch. Present so every behaviour here is provable without a DOM. */
5
5
  type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
@@ -1,6 +1,6 @@
1
1
  import * as React from 'react';
2
- import { a as PortalAppointment, F as FetchLike, b as PortalAppointmentsBody, c as PortalResult } from './portal-account.client-CTget1-3.js';
3
- import './portal-account-EhOtLV5u.js';
2
+ import { a as PortalAppointment, F as FetchLike, b as PortalAppointmentsBody, c as PortalResult } from './portal-account.client-CqnsCCiC.js';
3
+ import './portal-account-CaK1lqSZ.js';
4
4
 
5
5
  /** One bookable instant, collapsing the practitioners free at the same time. */
6
6
  interface TimeSlot {
@@ -22,7 +22,7 @@ import {
22
22
  releasePortalHold,
23
23
  rescheduleContextTypeId,
24
24
  reschedulePortalAppointmentAndReload
25
- } from "./chunk-G3U6EXJM.js";
25
+ } from "./chunk-TMW4R76E.js";
26
26
  import "./chunk-MLKGABMK.js";
27
27
  export {
28
28
  AVAILABILITY_FAILED_COPY,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patientos/website-kit",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "PatientOS clinic website components and patient-facing interactive islands.",
5
5
  "type": "module",
6
6
  "sideEffects": [