@businessdash/sdk 0.9.60 → 0.9.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/README.md +991 -295
  2. package/assets/biab-sw.js +92 -0
  3. package/dist/account-core/index.d.ts +126 -0
  4. package/dist/account-core/index.d.ts.map +1 -0
  5. package/dist/account-core/index.js +191 -0
  6. package/dist/account-core/index.js.map +1 -0
  7. package/dist/account.d.ts +11 -0
  8. package/dist/account.d.ts.map +1 -0
  9. package/dist/account.js +11 -0
  10. package/dist/account.js.map +1 -0
  11. package/dist/auth-handler.d.ts +1 -1
  12. package/dist/auth-links.d.ts +33 -0
  13. package/dist/auth-links.d.ts.map +1 -0
  14. package/dist/auth-links.js +64 -0
  15. package/dist/auth-links.js.map +1 -0
  16. package/dist/blog-core/index.d.ts +112 -0
  17. package/dist/blog-core/index.d.ts.map +1 -0
  18. package/dist/blog-core/index.js +330 -0
  19. package/dist/blog-core/index.js.map +1 -0
  20. package/dist/blog.d.ts +11 -0
  21. package/dist/blog.d.ts.map +1 -0
  22. package/dist/blog.js +11 -0
  23. package/dist/blog.js.map +1 -0
  24. package/dist/chat-core/index.d.ts +97 -0
  25. package/dist/chat-core/index.d.ts.map +1 -0
  26. package/dist/chat-core/index.js +237 -0
  27. package/dist/chat-core/index.js.map +1 -0
  28. package/dist/chat.d.ts +11 -0
  29. package/dist/chat.d.ts.map +1 -0
  30. package/dist/chat.js +11 -0
  31. package/dist/chat.js.map +1 -0
  32. package/dist/checkout-core/index.d.ts +65 -0
  33. package/dist/checkout-core/index.d.ts.map +1 -0
  34. package/dist/checkout-core/index.js +159 -0
  35. package/dist/checkout-core/index.js.map +1 -0
  36. package/dist/checkout.d.ts +10 -0
  37. package/dist/checkout.d.ts.map +1 -0
  38. package/dist/checkout.js +10 -0
  39. package/dist/checkout.js.map +1 -0
  40. package/dist/cli.js +133 -25
  41. package/dist/cli.js.map +1 -1
  42. package/dist/client.d.ts +622 -1
  43. package/dist/client.d.ts.map +1 -1
  44. package/dist/client.js +888 -1
  45. package/dist/client.js.map +1 -1
  46. package/dist/consent-core/index.cjs +107 -0
  47. package/dist/consent-core/index.d.ts +103 -0
  48. package/dist/consent-core/index.d.ts.map +1 -0
  49. package/dist/consent-core/index.js +157 -0
  50. package/dist/consent-core/index.js.map +1 -0
  51. package/dist/contracts.d.ts +3526 -767
  52. package/dist/contracts.d.ts.map +1 -1
  53. package/dist/contracts.js +571 -4
  54. package/dist/contracts.js.map +1 -1
  55. package/dist/data-model-schema.d.ts +1 -1
  56. package/dist/data-model-schema.d.ts.map +1 -1
  57. package/dist/element.d.ts +30 -0
  58. package/dist/element.d.ts.map +1 -1
  59. package/dist/element.js +56 -0
  60. package/dist/element.js.map +1 -1
  61. package/dist/embed-protocol.d.ts +86 -0
  62. package/dist/embed-protocol.d.ts.map +1 -0
  63. package/dist/embed-protocol.js +60 -0
  64. package/dist/embed-protocol.js.map +1 -0
  65. package/dist/framework-support.d.ts +122 -0
  66. package/dist/framework-support.d.ts.map +1 -0
  67. package/dist/framework-support.js +323 -0
  68. package/dist/framework-support.js.map +1 -0
  69. package/dist/index.cjs +1355 -24
  70. package/dist/legal-core/index.cjs +115 -0
  71. package/dist/legal-core/index.d.ts +160 -0
  72. package/dist/legal-core/index.d.ts.map +1 -0
  73. package/dist/legal-core/index.js +220 -0
  74. package/dist/legal-core/index.js.map +1 -0
  75. package/dist/marketing-core/index.d.ts +66 -0
  76. package/dist/marketing-core/index.d.ts.map +1 -0
  77. package/dist/marketing-core/index.js +154 -0
  78. package/dist/marketing-core/index.js.map +1 -0
  79. package/dist/marketing.d.ts +7 -0
  80. package/dist/marketing.d.ts.map +1 -0
  81. package/dist/marketing.js +7 -0
  82. package/dist/marketing.js.map +1 -0
  83. package/dist/mcp-adapters/index.d.ts +135 -0
  84. package/dist/mcp-adapters/index.d.ts.map +1 -0
  85. package/dist/mcp-adapters/index.js +187 -0
  86. package/dist/mcp-adapters/index.js.map +1 -0
  87. package/dist/mcp-adapters.d.ts +7 -0
  88. package/dist/mcp-adapters.d.ts.map +1 -0
  89. package/dist/mcp-adapters.js +7 -0
  90. package/dist/mcp-adapters.js.map +1 -0
  91. package/dist/model3d-core/index.cjs +95 -0
  92. package/dist/model3d-core/index.d.ts +167 -0
  93. package/dist/model3d-core/index.d.ts.map +1 -0
  94. package/dist/model3d-core/index.js +158 -0
  95. package/dist/model3d-core/index.js.map +1 -0
  96. package/dist/notifications-core/index.cjs +315 -0
  97. package/dist/notifications-core/index.d.ts +229 -0
  98. package/dist/notifications-core/index.d.ts.map +1 -0
  99. package/dist/notifications-core/index.js +445 -0
  100. package/dist/notifications-core/index.js.map +1 -0
  101. package/dist/portal-core/index.d.ts +451 -0
  102. package/dist/portal-core/index.d.ts.map +1 -0
  103. package/dist/portal-core/index.js +819 -0
  104. package/dist/portal-core/index.js.map +1 -0
  105. package/dist/portal.d.ts +7 -0
  106. package/dist/portal.d.ts.map +1 -0
  107. package/dist/portal.js +7 -0
  108. package/dist/portal.js.map +1 -0
  109. package/dist/push-core/index.cjs +171 -0
  110. package/dist/push-core/index.d.ts +149 -0
  111. package/dist/push-core/index.d.ts.map +1 -0
  112. package/dist/push-core/index.js +231 -0
  113. package/dist/push-core/index.js.map +1 -0
  114. package/dist/react-marketing.d.ts +1 -17
  115. package/dist/react-marketing.d.ts.map +1 -1
  116. package/dist/react-marketing.js +4 -0
  117. package/dist/react-marketing.js.map +1 -1
  118. package/dist/react.d.ts +294 -101
  119. package/dist/react.d.ts.map +1 -1
  120. package/dist/react.js +302 -246
  121. package/dist/react.js.map +1 -1
  122. package/dist/seed-core/index.d.ts +354 -0
  123. package/dist/seed-core/index.d.ts.map +1 -0
  124. package/dist/seed-core/index.js +420 -0
  125. package/dist/seed-core/index.js.map +1 -0
  126. package/dist/seed.d.ts +7 -0
  127. package/dist/seed.d.ts.map +1 -0
  128. package/dist/seed.js +7 -0
  129. package/dist/seed.js.map +1 -0
  130. package/dist/seo-core/blog.d.ts +94 -0
  131. package/dist/seo-core/blog.d.ts.map +1 -0
  132. package/dist/seo-core/blog.js +149 -0
  133. package/dist/seo-core/blog.js.map +1 -0
  134. package/dist/seo-core/commerce.d.ts +64 -0
  135. package/dist/seo-core/commerce.d.ts.map +1 -0
  136. package/dist/seo-core/commerce.js +87 -0
  137. package/dist/seo-core/commerce.js.map +1 -0
  138. package/dist/seo-core/index.cjs +751 -0
  139. package/dist/seo-core/index.d.ts +226 -0
  140. package/dist/seo-core/index.d.ts.map +1 -0
  141. package/dist/seo-core/index.js +455 -0
  142. package/dist/seo-core/index.js.map +1 -0
  143. package/dist/seo-core/local.d.ts +181 -0
  144. package/dist/seo-core/local.d.ts.map +1 -0
  145. package/dist/seo-core/local.js +193 -0
  146. package/dist/seo-core/local.js.map +1 -0
  147. package/dist/sitemap-core/index.cjs +167 -0
  148. package/dist/sitemap-core/index.d.ts +155 -0
  149. package/dist/sitemap-core/index.d.ts.map +1 -0
  150. package/dist/sitemap-core/index.js +195 -0
  151. package/dist/sitemap-core/index.js.map +1 -0
  152. package/dist/social-core/index.d.ts +74 -0
  153. package/dist/social-core/index.d.ts.map +1 -0
  154. package/dist/social-core/index.js +72 -0
  155. package/dist/social-core/index.js.map +1 -0
  156. package/dist/social.d.ts +7 -0
  157. package/dist/social.d.ts.map +1 -0
  158. package/dist/social.js +7 -0
  159. package/dist/social.js.map +1 -0
  160. package/dist/solid-compat.d.ts +204 -0
  161. package/dist/solid-compat.d.ts.map +1 -0
  162. package/dist/solid-compat.js +221 -0
  163. package/dist/solid-compat.js.map +1 -0
  164. package/dist/store-core/index.d.ts +141 -0
  165. package/dist/store-core/index.d.ts.map +1 -0
  166. package/dist/store-core/index.js +277 -0
  167. package/dist/store-core/index.js.map +1 -0
  168. package/dist/store.d.ts +12 -0
  169. package/dist/store.d.ts.map +1 -0
  170. package/dist/store.js +12 -0
  171. package/dist/store.js.map +1 -0
  172. package/dist/vanilla.d.ts +111 -0
  173. package/dist/vanilla.d.ts.map +1 -1
  174. package/dist/vanilla.js +206 -0
  175. package/dist/vanilla.js.map +1 -1
  176. package/dist/verify-core/index.d.ts +98 -0
  177. package/dist/verify-core/index.d.ts.map +1 -0
  178. package/dist/verify-core/index.js +186 -0
  179. package/dist/verify-core/index.js.map +1 -0
  180. package/dist/verify.d.ts +8 -0
  181. package/dist/verify.d.ts.map +1 -0
  182. package/dist/verify.js +8 -0
  183. package/dist/verify.js.map +1 -0
  184. package/openapi.json +4516 -1311
  185. package/package.json +128 -11
  186. package/src/account-core/index.ts +309 -0
  187. package/src/account.ts +11 -0
  188. package/src/angular/headless.ts +519 -0
  189. package/src/angular/index.ts +3 -0
  190. package/src/auth-links.ts +76 -0
  191. package/src/blog-core/index.ts +423 -0
  192. package/src/blog.ts +11 -0
  193. package/src/chat-core/index.ts +335 -0
  194. package/src/chat.ts +11 -0
  195. package/src/checkout-core/index.ts +225 -0
  196. package/src/checkout.ts +10 -0
  197. package/src/cli.ts +184 -28
  198. package/src/client.ts +1097 -0
  199. package/src/consent-core/index.ts +213 -0
  200. package/src/contracts.ts +768 -11
  201. package/src/data-model-schema.ts +1 -1
  202. package/src/element.ts +102 -0
  203. package/src/embed-protocol.ts +100 -0
  204. package/src/framework-support.ts +440 -0
  205. package/src/legal-core/index.ts +276 -0
  206. package/src/marketing-core/index.ts +209 -0
  207. package/src/marketing.ts +7 -0
  208. package/src/mcp-adapters/index.ts +264 -0
  209. package/src/mcp-adapters.ts +7 -0
  210. package/src/model3d-core/index.ts +248 -0
  211. package/src/notifications-core/index.ts +650 -0
  212. package/src/portal-core/index.ts +1564 -0
  213. package/src/portal.ts +7 -0
  214. package/src/push-core/index.ts +335 -0
  215. package/src/qwik.tsx +602 -2
  216. package/src/react-marketing.tsx +5 -0
  217. package/src/react.tsx +434 -319
  218. package/src/seed-core/index.ts +735 -0
  219. package/src/seed.ts +7 -0
  220. package/src/seo-core/blog.ts +216 -0
  221. package/src/seo-core/commerce.ts +138 -0
  222. package/src/seo-core/index.ts +605 -0
  223. package/src/seo-core/local.ts +333 -0
  224. package/src/sitemap-core/index.ts +296 -0
  225. package/src/social-core/index.ts +103 -0
  226. package/src/social.ts +7 -0
  227. package/src/solid-compat.tsx +347 -0
  228. package/src/solid-surfaces.tsx +1311 -0
  229. package/src/solid.tsx +82 -60
  230. package/src/store-core/index.ts +426 -0
  231. package/src/store.ts +12 -0
  232. package/src/svelte/SocialLinks.svelte +84 -0
  233. package/src/svelte/headless.ts +435 -0
  234. package/src/svelte/index.ts +9 -0
  235. package/src/vanilla.ts +367 -0
  236. package/src/verify-core/index.ts +288 -0
  237. package/src/verify.ts +8 -0
  238. package/src/vue/headless.ts +527 -0
  239. package/src/vue/index.ts +3 -0
@@ -0,0 +1,819 @@
1
+ /**
2
+ * `@businessdash/sdk/portal` — the headless customer-portal controller.
3
+ *
4
+ * The portal is the largest surface in the SDK: 34 methods covering work,
5
+ * quotes, contracts, invoices, orders, downloads, shipping, reviews, referrals,
6
+ * profile and notification preferences. Until now the only way to render it was
7
+ * `<Dashboard>`, a BusinessDash-hosted page in an iframe — so a business could
8
+ * not put their own customer portal on their own domain in their own design.
9
+ *
10
+ * This is the native path. Four things about the surface shape the design, and
11
+ * all of them come from the audit in `Resources/BIAB-Customer-Portal-SDK-Audit.md`.
12
+ *
13
+ * ## 1. The session is not optional
14
+ *
15
+ * Every portal route requires a signed-in session token AND the scope
16
+ * `customer_portal:self`. Without `withSession` the client has no identity and
17
+ * every call is unauthenticated — which fails, but quietly enough to look like
18
+ * an empty portal. So the token is a REQUIRED constructor argument and the
19
+ * controller calls `withSession` itself. A consumer cannot forget it.
20
+ *
21
+ * ## 2. There are no list endpoints, except orders and payments
22
+ *
23
+ * Invoices, quotes, contracts and shipments have detail-by-id routes only.
24
+ * `getWork()` returns a summary — open job count, unpaid balance — so a portal
25
+ * can render counts, but "show me all my unpaid invoices" has no single call.
26
+ * This controller does NOT paper over that with a fake list built from
27
+ * repeated detail fetches: it exposes what exists, and the gap stays visible
28
+ * where someone can decide to close it in the API.
29
+ *
30
+ * ## 3. Nothing can be deleted
31
+ *
32
+ * Every write is a create or an edit. There is deliberately no delete action
33
+ * anywhere in this controller, because there is none in the API.
34
+ *
35
+ * ## 4. Reviews are moderated, not published
36
+ *
37
+ * `submitReview` lands `status: "pending"` for staff approval. The controller
38
+ * does NOT add the new review to `reviews` — showing it back immediately would
39
+ * tell the author their review is live when the public cannot see it.
40
+ *
41
+ * @module
42
+ */
43
+ import { currentPushSubscription, pushSupport, subscribeToPush, unsubscribeFromPush, } from "../push-core/index.js";
44
+ function toError(cause, fallback) {
45
+ return cause instanceof Error ? cause : new Error(fallback);
46
+ }
47
+ export function createPortalController(options) {
48
+ const { onError } = options;
49
+ if (!options.sessionToken) {
50
+ throw new Error("createPortalController requires a `sessionToken`. Every customer-portal route is scoped to the signed-in customer; without one every call is unauthenticated and the portal renders empty rather than signed-out.");
51
+ }
52
+ const portal = options.client
53
+ .customerPortal()
54
+ .withSession(options.sessionToken);
55
+ /**
56
+ * A portal client pinned to another of the customer's orgs.
57
+ *
58
+ * Memoised because a dashboard listing five companies would otherwise build
59
+ * a client per render. The session token is the same — it identifies the
60
+ * PERSON; the org id only says which tenant's data to read, and the server
61
+ * still checks they belong to it.
62
+ */
63
+ const orgClients = new Map();
64
+ const portalFor = (orgId) => {
65
+ let pinned = orgClients.get(orgId);
66
+ if (!pinned) {
67
+ pinned = options.client
68
+ .customerPortal(orgId)
69
+ .withSession(options.sessionToken);
70
+ orgClients.set(orgId, pinned);
71
+ }
72
+ return pinned;
73
+ };
74
+ let destroyed = false;
75
+ /** Guards a slow detail fetch landing after the customer navigated on. */
76
+ let detailToken = 0;
77
+ const subscribers = new Set();
78
+ const state = {
79
+ status: "idle",
80
+ context: null,
81
+ profile: null,
82
+ work: null,
83
+ push: null,
84
+ notificationPreferences: null,
85
+ notificationPreferencesByOrg: {},
86
+ otherOrgs: null,
87
+ orders: null,
88
+ payments: null,
89
+ reviews: null,
90
+ invoices: null,
91
+ quotes: null,
92
+ contracts: null,
93
+ shipments: null,
94
+ messages: null,
95
+ returnRequests: null,
96
+ productReviews: null,
97
+ subscription: null,
98
+ subscriberContent: null,
99
+ productReviewAwaitingModeration: false,
100
+ lastNotReceived: null,
101
+ referrals: { stats: null, history: null, payouts: null },
102
+ activeJob: null,
103
+ jobActivity: null,
104
+ jobEta: null,
105
+ jobComments: null,
106
+ activeOrder: null,
107
+ orderDownloads: null,
108
+ activeInvoice: null,
109
+ activeQuote: null,
110
+ activeContract: null,
111
+ shipmentTracking: null,
112
+ reviewAwaitingModeration: false,
113
+ error: null,
114
+ pending: {
115
+ overview: false,
116
+ orders: false,
117
+ payments: false,
118
+ reviews: false,
119
+ referrals: false,
120
+ detail: false,
121
+ action: false,
122
+ },
123
+ };
124
+ let cached = null;
125
+ function snapshot() {
126
+ if (!cached) {
127
+ cached = {
128
+ status: state.status,
129
+ context: state.context,
130
+ profile: state.profile,
131
+ work: state.work,
132
+ push: state.push,
133
+ notificationPreferences: state.notificationPreferences,
134
+ notificationPreferencesByOrg: state.notificationPreferencesByOrg,
135
+ otherOrgs: state.otherOrgs,
136
+ orders: state.orders,
137
+ payments: state.payments,
138
+ reviews: state.reviews,
139
+ invoices: state.invoices,
140
+ quotes: state.quotes,
141
+ contracts: state.contracts,
142
+ shipments: state.shipments,
143
+ messages: state.messages,
144
+ returnRequests: state.returnRequests,
145
+ productReviews: state.productReviews,
146
+ subscription: state.subscription,
147
+ subscriberContent: state.subscriberContent,
148
+ productReviewAwaitingModeration: state.productReviewAwaitingModeration,
149
+ lastNotReceived: state.lastNotReceived,
150
+ unreadMessageCount: (state.messages?.items ?? []).filter((m) => m.direction === "outbound" && m.readByCustomerAt === null).length,
151
+ referrals: state.referrals,
152
+ activeJob: state.activeJob,
153
+ jobActivity: state.jobActivity,
154
+ jobEta: state.jobEta,
155
+ jobComments: state.jobComments,
156
+ activeOrder: state.activeOrder,
157
+ orderDownloads: state.orderDownloads,
158
+ activeInvoice: state.activeInvoice,
159
+ activeQuote: state.activeQuote,
160
+ activeContract: state.activeContract,
161
+ shipmentTracking: state.shipmentTracking,
162
+ reviewAwaitingModeration: state.reviewAwaitingModeration,
163
+ error: state.error,
164
+ pending: state.pending,
165
+ };
166
+ }
167
+ return cached;
168
+ }
169
+ function notify() {
170
+ if (destroyed)
171
+ return;
172
+ cached = null;
173
+ const next = snapshot();
174
+ for (const cb of subscribers)
175
+ cb(next);
176
+ }
177
+ function fail(cause, fallback) {
178
+ const error = toError(cause, fallback);
179
+ state.error = error;
180
+ if (state.status !== "ready")
181
+ state.status = "error";
182
+ onError?.(error);
183
+ }
184
+ /** Run a read that fills one slice of the snapshot. */
185
+ async function read(key, work, fallback) {
186
+ if (destroyed)
187
+ return;
188
+ state.pending[key] = true;
189
+ notify();
190
+ try {
191
+ await work();
192
+ if (!destroyed)
193
+ state.status = "ready";
194
+ }
195
+ catch (cause) {
196
+ if (!destroyed)
197
+ fail(cause, fallback);
198
+ }
199
+ finally {
200
+ if (!destroyed) {
201
+ state.pending[key] = false;
202
+ notify();
203
+ }
204
+ }
205
+ }
206
+ /** Run a write. Returns false rather than throwing, so a form can branch. */
207
+ async function act(work, fallback, after) {
208
+ if (destroyed)
209
+ return null;
210
+ state.pending.action = true;
211
+ state.error = null;
212
+ notify();
213
+ try {
214
+ const result = await work();
215
+ if (destroyed)
216
+ return null;
217
+ after?.(result);
218
+ return result;
219
+ }
220
+ catch (cause) {
221
+ if (!destroyed)
222
+ fail(cause, fallback);
223
+ return null;
224
+ }
225
+ finally {
226
+ if (!destroyed) {
227
+ state.pending.action = false;
228
+ notify();
229
+ }
230
+ }
231
+ }
232
+ /** Fill a detail slot, dropping the result if the customer moved on. */
233
+ async function detail(work, fallback) {
234
+ if (destroyed)
235
+ return;
236
+ const token = ++detailToken;
237
+ state.pending.detail = true;
238
+ state.error = null;
239
+ notify();
240
+ try {
241
+ await work(token);
242
+ }
243
+ catch (cause) {
244
+ if (!destroyed && token === detailToken)
245
+ fail(cause, fallback);
246
+ }
247
+ finally {
248
+ if (!destroyed && token === detailToken) {
249
+ state.pending.detail = false;
250
+ notify();
251
+ }
252
+ }
253
+ }
254
+ return {
255
+ snapshot,
256
+ subscribe(callback) {
257
+ subscribers.add(callback);
258
+ return () => {
259
+ subscribers.delete(callback);
260
+ };
261
+ },
262
+ load() {
263
+ if (state.status === "idle")
264
+ state.status = "loading";
265
+ return read("overview", async () => {
266
+ // Context, profile and work are independent, so one failing
267
+ // should not hide the other two.
268
+ const [context, profile, work] = await Promise.allSettled([
269
+ portal.context(),
270
+ portal.getProfile(),
271
+ portal.getWork(),
272
+ ]);
273
+ if (destroyed)
274
+ return;
275
+ if (context.status === "fulfilled")
276
+ state.context = context.value;
277
+ if (profile.status === "fulfilled")
278
+ state.profile = profile.value;
279
+ if (work.status === "fulfilled")
280
+ state.work = work.value;
281
+ const firstFailure = [context, profile, work].find((r) => r.status === "rejected");
282
+ if (firstFailure && firstFailure.status === "rejected") {
283
+ throw firstFailure.reason;
284
+ }
285
+ }, "Failed to load the portal.");
286
+ },
287
+ loadOrders(opts) {
288
+ return read("orders", async () => {
289
+ state.orders = await portal.listOrders(opts);
290
+ }, "Failed to load orders.");
291
+ },
292
+ loadInvoices(opts) {
293
+ return read("orders", async () => {
294
+ state.invoices = await portal.listInvoices(opts);
295
+ }, "Failed to load invoices.");
296
+ },
297
+ loadQuotes(opts) {
298
+ return read("orders", async () => {
299
+ state.quotes = await portal.listQuotes(opts);
300
+ }, "Failed to load quotes.");
301
+ },
302
+ loadContracts(opts) {
303
+ return read("orders", async () => {
304
+ state.contracts = await portal.listContracts(opts);
305
+ }, "Failed to load contracts.");
306
+ },
307
+ loadShipments(opts) {
308
+ return read("orders", async () => {
309
+ state.shipments = await portal.listShipments(opts);
310
+ }, "Failed to load shipments.");
311
+ },
312
+ loadMessages(opts) {
313
+ return read("orders", async () => {
314
+ state.messages = await portal.listMessages(opts);
315
+ }, "Failed to load your messages.");
316
+ },
317
+ async postMessage(input) {
318
+ const res = await act(() => portal.postMessage(input), "Failed to send your message.");
319
+ if (!res)
320
+ return false;
321
+ // Re-read rather than append: the stored row carries the author name
322
+ // and timestamp the server assigned, and a locally-appended message
323
+ // that later differs is the kind of thing a customer notices.
324
+ await read("orders", async () => {
325
+ state.messages = await portal.listMessages(input.jobId ? { jobId: input.jobId } : undefined);
326
+ }, "Message sent, but the thread could not be refreshed.");
327
+ return true;
328
+ },
329
+ async markMessagesRead() {
330
+ await act(() => portal.markMessagesRead(), "Failed to mark your messages read.", () => {
331
+ // Stamp locally so the badge clears immediately rather than
332
+ // waiting for a re-read the caller may never do.
333
+ if (state.messages) {
334
+ const now = new Date().toISOString();
335
+ state.messages = {
336
+ ...state.messages,
337
+ items: state.messages.items.map((m) => m.direction === "outbound" && m.readByCustomerAt === null
338
+ ? { ...m, readByCustomerAt: now }
339
+ : m),
340
+ };
341
+ }
342
+ });
343
+ },
344
+ loadPayments(opts) {
345
+ return read("payments", async () => {
346
+ state.payments = await portal.listPayments(opts);
347
+ }, "Failed to load payment history.");
348
+ },
349
+ loadReviews() {
350
+ return read("reviews", async () => {
351
+ state.reviews = await portal.listMyReviews();
352
+ }, "Failed to load your reviews.");
353
+ },
354
+ loadReferrals() {
355
+ return read("referrals", async () => {
356
+ const [stats, history, payouts] = await Promise.all([
357
+ portal.myReferralStats(),
358
+ portal.myReferralHistory(),
359
+ portal.myReferralPayouts(),
360
+ ]);
361
+ if (destroyed)
362
+ return;
363
+ state.referrals = { stats, history, payouts };
364
+ }, "Failed to load referrals.");
365
+ },
366
+ loadPushConfig() {
367
+ return read(
368
+ // "overview" rather than a slice of its own: the device list is
369
+ // small, loads with the rest of a settings page, and one more
370
+ // pending flag is one more thing every binding has to thread.
371
+ "overview", async () => {
372
+ state.push = await portal.getPushConfig();
373
+ }, "Failed to load your notification devices.");
374
+ },
375
+ async enablePush(options) {
376
+ // Support is checked BEFORE the config fetch: on an iPhone in a
377
+ // normal tab there is nothing a key could enable, and a round trip
378
+ // that ends in "add this to your Home Screen" is a wasted one.
379
+ const support = pushSupport();
380
+ if (!support.supported) {
381
+ return {
382
+ ok: false,
383
+ reason: support.reason === "server" ? "unsupported" : support.reason,
384
+ message: support.message,
385
+ };
386
+ }
387
+ const config = await act(() => portal.getPushConfig(), "Failed to start setting up notifications.");
388
+ if (!config) {
389
+ return {
390
+ ok: false,
391
+ reason: "failed",
392
+ message: "Failed to start setting up notifications.",
393
+ };
394
+ }
395
+ if (!config.enabled || !config.vapidPublicKey) {
396
+ // The platform has no VAPID pair configured. Nothing the visitor
397
+ // or the org can do about it, so say so plainly instead of
398
+ // failing at `subscribe()` with a DOMException nobody can act on.
399
+ return {
400
+ ok: false,
401
+ reason: "not-configured",
402
+ message: "Push notifications aren't available for this site yet.",
403
+ };
404
+ }
405
+ const subscribed = await subscribeToPush({
406
+ vapidPublicKey: config.vapidPublicKey,
407
+ ...(options?.serviceWorkerPath === undefined
408
+ ? {}
409
+ : { serviceWorkerPath: options.serviceWorkerPath }),
410
+ ...(options?.scope === undefined ? {} : { scope: options.scope }),
411
+ });
412
+ if (!subscribed.ok) {
413
+ return {
414
+ ok: false,
415
+ reason: subscribed.reason,
416
+ message: subscribed.message,
417
+ };
418
+ }
419
+ const saved = await act(() => portal.subscribePush({
420
+ endpoint: subscribed.subscription.endpoint,
421
+ p256dh: subscribed.subscription.p256dh,
422
+ auth: subscribed.subscription.auth,
423
+ userAgent: subscribed.subscription.userAgent ?? null,
424
+ label: options?.label ?? null,
425
+ }), "Notifications were allowed, but we couldn't finish setting them up.");
426
+ if (!saved) {
427
+ // The browser now holds a subscription the server does not know
428
+ // about. Roll it back rather than leaving a device that looks
429
+ // enabled locally and receives nothing — a state whose only
430
+ // symptom is silence.
431
+ await unsubscribeFromPush().catch(() => undefined);
432
+ return {
433
+ ok: false,
434
+ reason: "failed",
435
+ message: "Notifications were allowed, but we couldn't finish setting them up. Try again.",
436
+ };
437
+ }
438
+ await this.loadPushConfig();
439
+ return { ok: true, deviceId: saved.id };
440
+ },
441
+ async disablePush() {
442
+ // Read the endpoint BEFORE tearing the local subscription down —
443
+ // it is what the server keys on, and once `unsubscribe()` has run
444
+ // there is nothing left to look the row up by.
445
+ const local = await currentPushSubscription().catch(() => null);
446
+ const torn = await unsubscribeFromPush().catch(() => ({
447
+ endpoint: null,
448
+ }));
449
+ const endpoint = torn.endpoint ?? local?.endpoint ?? null;
450
+ if (!endpoint) {
451
+ // Nothing registered here. Refresh anyway, since a device may
452
+ // have been removed from somewhere else.
453
+ await this.loadPushConfig();
454
+ return true;
455
+ }
456
+ const result = await act(() => portal.unsubscribePush({ endpoint }), "Failed to turn notifications off.");
457
+ if (result)
458
+ await this.loadPushConfig();
459
+ return Boolean(result?.ok);
460
+ },
461
+ async updatePushDevice(input) {
462
+ const result = await act(() => portal.updatePushDevice(input), "Failed to update that device.");
463
+ if (result?.ok)
464
+ await this.loadPushConfig();
465
+ return Boolean(result?.ok);
466
+ },
467
+ async forgetPushDevice(id) {
468
+ const result = await act(() => portal.unsubscribePush({ id }), "Failed to remove that device.");
469
+ if (result?.ok)
470
+ await this.loadPushConfig();
471
+ return Boolean(result?.ok);
472
+ },
473
+ loadNotificationPreferences() {
474
+ return read("overview", async () => {
475
+ state.notificationPreferences =
476
+ await portal.getNotificationPreferences();
477
+ }, "Failed to load notification preferences.");
478
+ },
479
+ loadOtherOrgs() {
480
+ return read("overview", async () => {
481
+ state.otherOrgs = await portal.myOtherCustomerOrgs();
482
+ }, "Failed to load your other companies.");
483
+ },
484
+ loadNotificationPreferencesFor(orgId) {
485
+ return read("overview", async () => {
486
+ const prefs = await portalFor(orgId).getNotificationPreferences();
487
+ // Replace the whole record rather than mutating in place, so
488
+ // framework bindings comparing snapshot identity actually see it.
489
+ state.notificationPreferencesByOrg = {
490
+ ...state.notificationPreferencesByOrg,
491
+ [orgId]: prefs,
492
+ };
493
+ }, "Failed to load notification preferences for that company.");
494
+ },
495
+ async updateNotificationPreferencesFor(orgId, input) {
496
+ const res = await act(() => portalFor(orgId).updateNotificationPreferences(input), "Failed to update notification preferences for that company.");
497
+ if (res && !destroyed) {
498
+ // The server merges sparse writes into the stored matrix, so the
499
+ // authoritative result is what came BACK — echoing the request
500
+ // would show the customer a matrix the server never agreed to.
501
+ state.notificationPreferencesByOrg = {
502
+ ...state.notificationPreferencesByOrg,
503
+ [orgId]: res,
504
+ };
505
+ notify();
506
+ }
507
+ return res !== null;
508
+ },
509
+ openJob(jobId) {
510
+ return detail(async (token) => {
511
+ const job = await portal.getJob(jobId);
512
+ if (destroyed || token !== detailToken)
513
+ return;
514
+ state.activeJob = job;
515
+ notify();
516
+ // Activity, ETA and comments are supporting detail. Any of them
517
+ // failing must not blank the job the customer asked for.
518
+ const [activity, eta, comments] = await Promise.allSettled([
519
+ portal.getJobActivity(jobId),
520
+ portal.getJobEta(jobId),
521
+ portal.listJobComments(jobId),
522
+ ]);
523
+ if (destroyed || token !== detailToken)
524
+ return;
525
+ state.jobActivity =
526
+ activity.status === "fulfilled" ? activity.value : null;
527
+ state.jobEta = eta.status === "fulfilled" ? eta.value : null;
528
+ state.jobComments =
529
+ comments.status === "fulfilled" ? comments.value : null;
530
+ }, "Failed to load the job.");
531
+ },
532
+ openOrder(orderId) {
533
+ return detail(async (token) => {
534
+ const order = await portal.getOrder(orderId);
535
+ if (destroyed || token !== detailToken)
536
+ return;
537
+ state.activeOrder = order;
538
+ notify();
539
+ // Digital downloads only exist for some orders; a 404 here is
540
+ // normal and must not look like the order failed to load.
541
+ // Downloads and returns are both optional detail on an order —
542
+ // most orders have neither, so a 404 on either is normal and must
543
+ // not look like the order failed to load.
544
+ const [downloads, returns] = await Promise.allSettled([
545
+ portal.getDownloads(orderId),
546
+ portal.listReturnRequests(orderId),
547
+ ]);
548
+ if (!destroyed && token === detailToken) {
549
+ state.orderDownloads =
550
+ downloads.status === "fulfilled" ? downloads.value : null;
551
+ state.returnRequests =
552
+ returns.status === "fulfilled" ? returns.value : null;
553
+ }
554
+ }, "Failed to load the order.");
555
+ },
556
+ openInvoice(invoiceId) {
557
+ return detail(async (token) => {
558
+ const invoice = await portal.getInvoice(invoiceId);
559
+ if (destroyed || token !== detailToken)
560
+ return;
561
+ state.activeInvoice = invoice;
562
+ }, "Failed to load the invoice.");
563
+ },
564
+ openQuote(quoteId) {
565
+ return detail(async (token) => {
566
+ // `viewQuote` both fetches and records that the customer looked,
567
+ // which the org can see. That is the right call from a portal:
568
+ // opening a quote IS viewing it.
569
+ const quote = await portal.viewQuote(quoteId);
570
+ if (destroyed || token !== detailToken)
571
+ return;
572
+ state.activeQuote = quote;
573
+ }, "Failed to load the quote.");
574
+ },
575
+ openContract(contractId) {
576
+ return detail(async (token) => {
577
+ const contract = await portal.getContract(contractId);
578
+ if (destroyed || token !== detailToken)
579
+ return;
580
+ state.activeContract = contract;
581
+ }, "Failed to load the contract.");
582
+ },
583
+ trackShipment(shipmentId) {
584
+ return detail(async (token) => {
585
+ const tracking = await portal.trackShipment(shipmentId);
586
+ if (destroyed || token !== detailToken)
587
+ return;
588
+ state.shipmentTracking = tracking;
589
+ }, "Failed to track the shipment.");
590
+ },
591
+ closeDetail() {
592
+ if (destroyed)
593
+ return;
594
+ detailToken += 1; // orphan anything in flight
595
+ state.activeJob = null;
596
+ state.jobActivity = null;
597
+ state.jobEta = null;
598
+ state.jobComments = null;
599
+ state.activeOrder = null;
600
+ state.orderDownloads = null;
601
+ state.returnRequests = null;
602
+ state.activeInvoice = null;
603
+ state.activeQuote = null;
604
+ state.activeContract = null;
605
+ state.shipmentTracking = null;
606
+ // Per-shipment, so it belongs to the detail view rather than the
607
+ // session — leaving it would show a stale claim on the next parcel.
608
+ state.lastNotReceived = null;
609
+ notify();
610
+ },
611
+ async postJobComment(jobId, input) {
612
+ const res = await act(() => portal.postJobComment(jobId, input), "Failed to post the comment.");
613
+ if (!res)
614
+ return false;
615
+ // Re-read rather than splice: the thread is the authority on what a
616
+ // comment looks like once the server has it.
617
+ await read("detail", async () => {
618
+ state.jobComments = await portal.listJobComments(jobId);
619
+ }, "Comment posted, but the thread could not be refreshed.");
620
+ return true;
621
+ },
622
+ async acceptQuote(quoteId) {
623
+ const res = await act(() => portal.acceptQuote(quoteId), "Failed to accept the quote.", () => {
624
+ // One-way state change; re-read so the UI shows the new status
625
+ // rather than the stale one it was rendered from.
626
+ void portal
627
+ .getQuote(quoteId)
628
+ .then((q) => {
629
+ if (!destroyed) {
630
+ state.activeQuote = q;
631
+ notify();
632
+ }
633
+ })
634
+ .catch(() => { });
635
+ });
636
+ return res !== null;
637
+ },
638
+ async rejectQuote(quoteId) {
639
+ const res = await act(() => portal.rejectQuote(quoteId), "Failed to reject the quote.", () => {
640
+ void portal
641
+ .getQuote(quoteId)
642
+ .then((q) => {
643
+ if (!destroyed) {
644
+ state.activeQuote = q;
645
+ notify();
646
+ }
647
+ })
648
+ .catch(() => { });
649
+ });
650
+ return res !== null;
651
+ },
652
+ async signContract(contractId) {
653
+ const res = await act(() => portal.signContract(contractId), "Failed to sign the contract.", () => {
654
+ // There is no unsign. Re-reading is the only way the UI learns
655
+ // the contract is now signed.
656
+ void portal
657
+ .getContract(contractId)
658
+ .then((c) => {
659
+ if (!destroyed) {
660
+ state.activeContract = c;
661
+ notify();
662
+ }
663
+ })
664
+ .catch(() => { });
665
+ });
666
+ return res !== null;
667
+ },
668
+ payInvoice(invoiceId, opts) {
669
+ return act(() => portal.payInvoice(invoiceId, opts), "Could not start the payment.");
670
+ },
671
+ async updateProfile(input) {
672
+ const res = await act(() => portal.updateProfile(input), "Failed to save your details.");
673
+ if (res === null)
674
+ return false;
675
+ await read("overview", async () => {
676
+ state.profile = await portal.getProfile();
677
+ }, "Saved, but your details could not be re-read.");
678
+ return true;
679
+ },
680
+ async updateNotificationPreferences(input) {
681
+ const res = await act(() => portal.updateNotificationPreferences(input), "Failed to save your notification settings.", (next) => {
682
+ state.notificationPreferences = next;
683
+ });
684
+ return res !== null;
685
+ },
686
+ async submitReview(input) {
687
+ const res = await act(() => portal.submitReview(input), "Failed to submit your review.", () => {
688
+ // Deliberately NOT added to `reviews`. It lands as `pending` for
689
+ // staff approval, and showing it back would tell the author it is
690
+ // live when the public cannot see it.
691
+ state.reviewAwaitingModeration = true;
692
+ });
693
+ return res !== null;
694
+ },
695
+ submitReturnRequest(orderId, input) {
696
+ return act(() => portal.submitReturnRequest(orderId, input), "Failed to submit the return request.", () => {
697
+ // Re-read so the customer immediately sees the request they just
698
+ // made, with its real status. Submitting into silence is what
699
+ // made this gap worth closing.
700
+ void portal
701
+ .listReturnRequests(orderId)
702
+ .then((res) => {
703
+ if (!destroyed) {
704
+ state.returnRequests = res;
705
+ notify();
706
+ }
707
+ })
708
+ .catch(() => { });
709
+ });
710
+ },
711
+ submitExchangeRequest(orderId, input) {
712
+ // Routed through the same method so the re-read, the error copy and
713
+ // the pending flag stay identical — an exchange is a return request
714
+ // with a different ask, not a separate flow.
715
+ return this.submitReturnRequest(orderId, {
716
+ ...(input ?? {}),
717
+ kind: "exchange",
718
+ });
719
+ },
720
+ reportNotReceived(shipmentId, input) {
721
+ return act(() => portal.reportNotReceived(shipmentId, input), "Failed to report the parcel as not received.").then((res) => {
722
+ if (res && !destroyed) {
723
+ state.lastNotReceived = res;
724
+ notify();
725
+ }
726
+ return res;
727
+ });
728
+ },
729
+ loadDispatchStatus(jobId) {
730
+ return detail(async (token) => {
731
+ const eta = await portal.getDispatchStatus(jobId);
732
+ if (destroyed || token !== detailToken)
733
+ return;
734
+ state.jobEta = eta;
735
+ }, "Failed to load the dispatch status.");
736
+ },
737
+ loadSubscription() {
738
+ return read("overview", async () => {
739
+ state.subscription = await portal.getSubscription();
740
+ }, "Failed to load your subscription.");
741
+ },
742
+ loadSubscriberContent(opts) {
743
+ return read("overview", async () => {
744
+ state.subscriberContent = await portal.getSubscriberContent(opts);
745
+ }, "Failed to load your subscriber content.");
746
+ },
747
+ async cancelSubscription() {
748
+ const res = await act(() => portal.cancelSubscription(), "Failed to cancel your subscription.");
749
+ if (res && !destroyed) {
750
+ // Re-read rather than patching the cached row: the provider's
751
+ // webhook owns `status`, and guessing what it will write is how a
752
+ // portal ends up showing "cancelled" while the customer still has
753
+ // — and has paid for — access.
754
+ void portal
755
+ .getSubscription()
756
+ .then((next) => {
757
+ if (!destroyed) {
758
+ state.subscription = next;
759
+ notify();
760
+ }
761
+ })
762
+ .catch(() => { });
763
+ }
764
+ return res;
765
+ },
766
+ async resumeSubscription() {
767
+ const res = await act(() => portal.resumeSubscription(), "Failed to resume your subscription.");
768
+ if (res && !destroyed) {
769
+ void portal
770
+ .getSubscription()
771
+ .then((next) => {
772
+ if (!destroyed) {
773
+ state.subscription = next;
774
+ notify();
775
+ }
776
+ })
777
+ .catch(() => { });
778
+ }
779
+ return res;
780
+ },
781
+ async loadProductReviews() {
782
+ await act(() => portal.listProductReviews(), "Failed to load your product reviews.", () => { }).then((res) => {
783
+ if (res && !destroyed) {
784
+ state.productReviews = res;
785
+ notify();
786
+ }
787
+ });
788
+ },
789
+ async submitProductReview(input) {
790
+ const res = await act(() => portal.submitProductReview(input), "Failed to submit your review.", () => { });
791
+ if (res && !destroyed) {
792
+ // Only flag moderation when the org actually held it. Saying
793
+ // "awaiting review" for a review that is already live is its own
794
+ // kind of wrong.
795
+ state.productReviewAwaitingModeration = res.awaitingModeration;
796
+ // Re-read so the customer sees what they wrote, with its real
797
+ // approval state, rather than having to trust that it landed.
798
+ void portal
799
+ .listProductReviews()
800
+ .then((list) => {
801
+ if (!destroyed) {
802
+ state.productReviews = list;
803
+ notify();
804
+ }
805
+ })
806
+ .catch(() => { });
807
+ }
808
+ return res !== null;
809
+ },
810
+ contactCompany(input) {
811
+ return act(() => portal.contactCompany(input), "Failed to send your message.");
812
+ },
813
+ destroy() {
814
+ destroyed = true;
815
+ subscribers.clear();
816
+ },
817
+ };
818
+ }
819
+ //# sourceMappingURL=index.js.map