@businessdash/sdk 0.9.60 → 0.9.70

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 (233) hide show
  1. package/README.md +1012 -186
  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 +567 -1
  43. package/dist/client.d.ts.map +1 -1
  44. package/dist/client.js +807 -1
  45. package/dist/client.js.map +1 -1
  46. package/dist/contracts.d.ts +3309 -761
  47. package/dist/contracts.d.ts.map +1 -1
  48. package/dist/contracts.js +541 -4
  49. package/dist/contracts.js.map +1 -1
  50. package/dist/data-model-schema.d.ts +1 -1
  51. package/dist/data-model-schema.d.ts.map +1 -1
  52. package/dist/element.d.ts +30 -0
  53. package/dist/element.d.ts.map +1 -1
  54. package/dist/element.js +56 -0
  55. package/dist/element.js.map +1 -1
  56. package/dist/embed-protocol.d.ts +86 -0
  57. package/dist/embed-protocol.d.ts.map +1 -0
  58. package/dist/embed-protocol.js +60 -0
  59. package/dist/embed-protocol.js.map +1 -0
  60. package/dist/framework-support.d.ts +122 -0
  61. package/dist/framework-support.d.ts.map +1 -0
  62. package/dist/framework-support.js +323 -0
  63. package/dist/framework-support.js.map +1 -0
  64. package/dist/index.cjs +1244 -5
  65. package/dist/legal-core/index.cjs +115 -0
  66. package/dist/legal-core/index.d.ts +160 -0
  67. package/dist/legal-core/index.d.ts.map +1 -0
  68. package/dist/legal-core/index.js +220 -0
  69. package/dist/legal-core/index.js.map +1 -0
  70. package/dist/marketing-core/index.d.ts +66 -0
  71. package/dist/marketing-core/index.d.ts.map +1 -0
  72. package/dist/marketing-core/index.js +154 -0
  73. package/dist/marketing-core/index.js.map +1 -0
  74. package/dist/marketing.d.ts +7 -0
  75. package/dist/marketing.d.ts.map +1 -0
  76. package/dist/marketing.js +7 -0
  77. package/dist/marketing.js.map +1 -0
  78. package/dist/mcp-adapters/index.d.ts +135 -0
  79. package/dist/mcp-adapters/index.d.ts.map +1 -0
  80. package/dist/mcp-adapters/index.js +187 -0
  81. package/dist/mcp-adapters/index.js.map +1 -0
  82. package/dist/mcp-adapters.d.ts +7 -0
  83. package/dist/mcp-adapters.d.ts.map +1 -0
  84. package/dist/mcp-adapters.js +7 -0
  85. package/dist/mcp-adapters.js.map +1 -0
  86. package/dist/model3d-core/index.cjs +95 -0
  87. package/dist/model3d-core/index.d.ts +167 -0
  88. package/dist/model3d-core/index.d.ts.map +1 -0
  89. package/dist/model3d-core/index.js +158 -0
  90. package/dist/model3d-core/index.js.map +1 -0
  91. package/dist/notifications-core/index.cjs +315 -0
  92. package/dist/notifications-core/index.d.ts +229 -0
  93. package/dist/notifications-core/index.d.ts.map +1 -0
  94. package/dist/notifications-core/index.js +445 -0
  95. package/dist/notifications-core/index.js.map +1 -0
  96. package/dist/portal-core/index.d.ts +451 -0
  97. package/dist/portal-core/index.d.ts.map +1 -0
  98. package/dist/portal-core/index.js +819 -0
  99. package/dist/portal-core/index.js.map +1 -0
  100. package/dist/portal.d.ts +7 -0
  101. package/dist/portal.d.ts.map +1 -0
  102. package/dist/portal.js +7 -0
  103. package/dist/portal.js.map +1 -0
  104. package/dist/push-core/index.cjs +171 -0
  105. package/dist/push-core/index.d.ts +149 -0
  106. package/dist/push-core/index.d.ts.map +1 -0
  107. package/dist/push-core/index.js +231 -0
  108. package/dist/push-core/index.js.map +1 -0
  109. package/dist/react-marketing.d.ts +1 -17
  110. package/dist/react-marketing.d.ts.map +1 -1
  111. package/dist/react-marketing.js +4 -0
  112. package/dist/react-marketing.js.map +1 -1
  113. package/dist/react.d.ts +294 -101
  114. package/dist/react.d.ts.map +1 -1
  115. package/dist/react.js +302 -246
  116. package/dist/react.js.map +1 -1
  117. package/dist/seed-core/index.d.ts +354 -0
  118. package/dist/seed-core/index.d.ts.map +1 -0
  119. package/dist/seed-core/index.js +420 -0
  120. package/dist/seed-core/index.js.map +1 -0
  121. package/dist/seed.d.ts +7 -0
  122. package/dist/seed.d.ts.map +1 -0
  123. package/dist/seed.js +7 -0
  124. package/dist/seed.js.map +1 -0
  125. package/dist/seo-core/blog.d.ts +94 -0
  126. package/dist/seo-core/blog.d.ts.map +1 -0
  127. package/dist/seo-core/blog.js +149 -0
  128. package/dist/seo-core/blog.js.map +1 -0
  129. package/dist/seo-core/commerce.d.ts +64 -0
  130. package/dist/seo-core/commerce.d.ts.map +1 -0
  131. package/dist/seo-core/commerce.js +87 -0
  132. package/dist/seo-core/commerce.js.map +1 -0
  133. package/dist/seo-core/index.cjs +751 -0
  134. package/dist/seo-core/index.d.ts +226 -0
  135. package/dist/seo-core/index.d.ts.map +1 -0
  136. package/dist/seo-core/index.js +455 -0
  137. package/dist/seo-core/index.js.map +1 -0
  138. package/dist/seo-core/local.d.ts +181 -0
  139. package/dist/seo-core/local.d.ts.map +1 -0
  140. package/dist/seo-core/local.js +193 -0
  141. package/dist/seo-core/local.js.map +1 -0
  142. package/dist/sitemap-core/index.cjs +167 -0
  143. package/dist/sitemap-core/index.d.ts +155 -0
  144. package/dist/sitemap-core/index.d.ts.map +1 -0
  145. package/dist/sitemap-core/index.js +195 -0
  146. package/dist/sitemap-core/index.js.map +1 -0
  147. package/dist/social-core/index.d.ts +74 -0
  148. package/dist/social-core/index.d.ts.map +1 -0
  149. package/dist/social-core/index.js +72 -0
  150. package/dist/social-core/index.js.map +1 -0
  151. package/dist/social.d.ts +7 -0
  152. package/dist/social.d.ts.map +1 -0
  153. package/dist/social.js +7 -0
  154. package/dist/social.js.map +1 -0
  155. package/dist/solid-compat.d.ts +204 -0
  156. package/dist/solid-compat.d.ts.map +1 -0
  157. package/dist/solid-compat.js +221 -0
  158. package/dist/solid-compat.js.map +1 -0
  159. package/dist/store-core/index.d.ts +141 -0
  160. package/dist/store-core/index.d.ts.map +1 -0
  161. package/dist/store-core/index.js +277 -0
  162. package/dist/store-core/index.js.map +1 -0
  163. package/dist/store.d.ts +12 -0
  164. package/dist/store.d.ts.map +1 -0
  165. package/dist/store.js +12 -0
  166. package/dist/store.js.map +1 -0
  167. package/dist/vanilla.d.ts +111 -0
  168. package/dist/vanilla.d.ts.map +1 -1
  169. package/dist/vanilla.js +206 -0
  170. package/dist/vanilla.js.map +1 -1
  171. package/dist/verify-core/index.d.ts +98 -0
  172. package/dist/verify-core/index.d.ts.map +1 -0
  173. package/dist/verify-core/index.js +186 -0
  174. package/dist/verify-core/index.js.map +1 -0
  175. package/dist/verify.d.ts +8 -0
  176. package/dist/verify.d.ts.map +1 -0
  177. package/dist/verify.js +8 -0
  178. package/dist/verify.js.map +1 -0
  179. package/openapi.json +4316 -1313
  180. package/package.json +121 -9
  181. package/src/account-core/index.ts +309 -0
  182. package/src/account.ts +11 -0
  183. package/src/angular/headless.ts +519 -0
  184. package/src/angular/index.ts +3 -0
  185. package/src/auth-links.ts +76 -0
  186. package/src/blog-core/index.ts +423 -0
  187. package/src/blog.ts +11 -0
  188. package/src/chat-core/index.ts +335 -0
  189. package/src/chat.ts +11 -0
  190. package/src/checkout-core/index.ts +225 -0
  191. package/src/checkout.ts +10 -0
  192. package/src/cli.ts +184 -28
  193. package/src/client.ts +998 -0
  194. package/src/contracts.ts +730 -11
  195. package/src/data-model-schema.ts +1 -1
  196. package/src/element.ts +102 -0
  197. package/src/embed-protocol.ts +100 -0
  198. package/src/framework-support.ts +440 -0
  199. package/src/legal-core/index.ts +276 -0
  200. package/src/marketing-core/index.ts +209 -0
  201. package/src/marketing.ts +7 -0
  202. package/src/mcp-adapters/index.ts +264 -0
  203. package/src/mcp-adapters.ts +7 -0
  204. package/src/model3d-core/index.ts +248 -0
  205. package/src/notifications-core/index.ts +650 -0
  206. package/src/portal-core/index.ts +1564 -0
  207. package/src/portal.ts +7 -0
  208. package/src/push-core/index.ts +335 -0
  209. package/src/qwik.tsx +602 -2
  210. package/src/react-marketing.tsx +5 -0
  211. package/src/react.tsx +434 -319
  212. package/src/seed-core/index.ts +735 -0
  213. package/src/seed.ts +7 -0
  214. package/src/seo-core/blog.ts +216 -0
  215. package/src/seo-core/commerce.ts +138 -0
  216. package/src/seo-core/index.ts +605 -0
  217. package/src/seo-core/local.ts +333 -0
  218. package/src/sitemap-core/index.ts +296 -0
  219. package/src/social-core/index.ts +103 -0
  220. package/src/social.ts +7 -0
  221. package/src/solid-compat.tsx +347 -0
  222. package/src/solid-surfaces.tsx +1311 -0
  223. package/src/solid.tsx +82 -60
  224. package/src/store-core/index.ts +426 -0
  225. package/src/store.ts +12 -0
  226. package/src/svelte/SocialLinks.svelte +84 -0
  227. package/src/svelte/headless.ts +435 -0
  228. package/src/svelte/index.ts +9 -0
  229. package/src/vanilla.ts +367 -0
  230. package/src/verify-core/index.ts +288 -0
  231. package/src/verify.ts +8 -0
  232. package/src/vue/headless.ts +527 -0
  233. package/src/vue/index.ts +3 -0
@@ -0,0 +1,1564 @@
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
+
44
+ import type {
45
+ CustomerContactCompanyInput,
46
+ CustomerContactCompanyResponse,
47
+ CustomerContractDetail,
48
+ CustomerContractListResponse,
49
+ CustomerContractSignResponse,
50
+ CustomerDownloadsResponse,
51
+ CustomerInvoiceDetail,
52
+ CustomerInvoiceListResponse,
53
+ CustomerInvoicePayInput,
54
+ CustomerInvoicePayResponse,
55
+ CustomerJobActivityResponse,
56
+ CustomerJobComment,
57
+ CustomerJobCommentPostInput,
58
+ CustomerJobCommentsListResponse,
59
+ CustomerJobDetail,
60
+ CustomerJobEtaResponse,
61
+ CustomerNotReceivedResult,
62
+ CustomerOrderDetail,
63
+ CustomerOrderListResponse,
64
+ CustomerPaymentHistoryResponse,
65
+ CustomerPortalContextResponse,
66
+ CustomerPortalMessage,
67
+ CustomerPortalMessageListResponse,
68
+ CustomerPortalMessagePostInput,
69
+ CustomerPortalOtherOrgsResponse,
70
+ CustomerPortalProfile,
71
+ CustomerPortalUpdateProfileInput,
72
+ CustomerProductReviewCreated,
73
+ CustomerProductReviewList,
74
+ CustomerPushConfig,
75
+ CustomerQuoteActionResponse,
76
+ CustomerQuoteDetail,
77
+ CustomerQuoteListResponse,
78
+ CustomerReferralHistoryResponse,
79
+ CustomerReferralPayoutsResponse,
80
+ CustomerReferralStats,
81
+ CustomerReturnRequestCreated,
82
+ CustomerReturnRequestInput,
83
+ CustomerReturnRequestListResponse,
84
+ CustomerReviewListResponse,
85
+ CustomerReviewSubmitInput,
86
+ CustomerShipmentListResponse,
87
+ CustomerShipmentTracking,
88
+ CustomerSubscriberContentResponse,
89
+ CustomerSubscriptionCancelResponse,
90
+ CustomerSubscriptionResponse,
91
+ CustomerWorkBundle,
92
+ NotificationPreferencesResponse,
93
+ NotificationPreferencesUpdateInput,
94
+ } from "../contracts.js";
95
+ import {
96
+ currentPushSubscription,
97
+ pushSupport,
98
+ subscribeToPush,
99
+ unsubscribeFromPush,
100
+ } from "../push-core/index.js";
101
+
102
+ /** The portal client, after `withSession` has bound an identity to it. */
103
+ export type PortalSessionClient = {
104
+ context(): Promise<CustomerPortalContextResponse>;
105
+ getProfile(): Promise<CustomerPortalProfile>;
106
+ updateProfile(input: CustomerPortalUpdateProfileInput): Promise<unknown>;
107
+ myOtherCustomerOrgs(): Promise<CustomerPortalOtherOrgsResponse>;
108
+ getPushConfig(): Promise<CustomerPushConfig>;
109
+ subscribePush(input: {
110
+ endpoint: string;
111
+ p256dh: string;
112
+ auth: string;
113
+ userAgent?: string | null;
114
+ label?: string | null;
115
+ categories?: string[] | null;
116
+ }): Promise<{ id: string; created: boolean }>;
117
+ updatePushDevice(input: {
118
+ id: string;
119
+ label?: string | null;
120
+ categories?: string[] | null;
121
+ }): Promise<{ ok: boolean }>;
122
+ unsubscribePush(input: {
123
+ id?: string;
124
+ endpoint?: string;
125
+ }): Promise<{ ok: boolean; deletedId: string | null }>;
126
+ getNotificationPreferences(): Promise<NotificationPreferencesResponse>;
127
+ updateNotificationPreferences(
128
+ input: NotificationPreferencesUpdateInput,
129
+ ): Promise<NotificationPreferencesResponse>;
130
+ getWork(): Promise<CustomerWorkBundle>;
131
+ getJob(jobId: string): Promise<CustomerJobDetail>;
132
+ getJobActivity(jobId: string): Promise<CustomerJobActivityResponse>;
133
+ getJobEta(jobId: string): Promise<CustomerJobEtaResponse>;
134
+ listJobComments(jobId: string): Promise<CustomerJobCommentsListResponse>;
135
+ postJobComment(
136
+ jobId: string,
137
+ input: CustomerJobCommentPostInput,
138
+ ): Promise<CustomerJobComment>;
139
+ getQuote(quoteId: string): Promise<CustomerQuoteDetail>;
140
+ viewQuote(quoteId: string): Promise<CustomerQuoteDetail>;
141
+ acceptQuote(quoteId: string): Promise<CustomerQuoteActionResponse>;
142
+ rejectQuote(quoteId: string): Promise<CustomerQuoteActionResponse>;
143
+ getInvoice(invoiceId: string): Promise<CustomerInvoiceDetail>;
144
+ payInvoice(
145
+ invoiceId: string,
146
+ opts?: CustomerInvoicePayInput,
147
+ ): Promise<CustomerInvoicePayResponse>;
148
+ getContract(contractId: string): Promise<CustomerContractDetail>;
149
+ signContract(contractId: string): Promise<CustomerContractSignResponse>;
150
+ listMyReviews(): Promise<CustomerReviewListResponse>;
151
+ submitReview(input: CustomerReviewSubmitInput): Promise<unknown>;
152
+ listOrders(opts?: { limit?: number }): Promise<CustomerOrderListResponse>;
153
+ listMessages(opts?: {
154
+ limit?: number;
155
+ jobId?: string;
156
+ }): Promise<CustomerPortalMessageListResponse>;
157
+ postMessage(
158
+ input: CustomerPortalMessagePostInput,
159
+ ): Promise<CustomerPortalMessage>;
160
+ markMessagesRead(): Promise<{ marked: number }>;
161
+ listReturnRequests(
162
+ orderId: string,
163
+ ): Promise<CustomerReturnRequestListResponse>;
164
+ listInvoices(opts?: {
165
+ limit?: number;
166
+ status?: string;
167
+ unpaid?: boolean;
168
+ }): Promise<CustomerInvoiceListResponse>;
169
+ listQuotes(opts?: {
170
+ limit?: number;
171
+ status?: string;
172
+ }): Promise<CustomerQuoteListResponse>;
173
+ listContracts(opts?: {
174
+ limit?: number;
175
+ status?: string;
176
+ }): Promise<CustomerContractListResponse>;
177
+ listShipments(opts?: {
178
+ limit?: number;
179
+ active?: boolean;
180
+ }): Promise<CustomerShipmentListResponse>;
181
+ getOrder(orderId: string): Promise<CustomerOrderDetail>;
182
+ getDownloads(orderId: string): Promise<CustomerDownloadsResponse>;
183
+ listPayments(opts?: {
184
+ limit?: number;
185
+ }): Promise<CustomerPaymentHistoryResponse>;
186
+ trackShipment(shipmentId: string): Promise<CustomerShipmentTracking>;
187
+ submitExchangeRequest(
188
+ orderId: string,
189
+ input?: Omit<CustomerReturnRequestInput, "kind">,
190
+ ): Promise<unknown>;
191
+ reportNotReceived(
192
+ shipmentId: string,
193
+ input?: { note?: string },
194
+ ): Promise<CustomerNotReceivedResult>;
195
+ listProductReviews(): Promise<CustomerProductReviewList>;
196
+ getDispatchStatus(jobId: string): Promise<CustomerJobEtaResponse>;
197
+ getSubscription(): Promise<CustomerSubscriptionResponse>;
198
+ cancelSubscription(): Promise<CustomerSubscriptionCancelResponse>;
199
+ resumeSubscription(): Promise<CustomerSubscriptionCancelResponse>;
200
+ getSubscriberContent(opts?: {
201
+ limit?: number;
202
+ }): Promise<CustomerSubscriberContentResponse>;
203
+ submitProductReview(input: {
204
+ productId: string;
205
+ rating: number;
206
+ content: string;
207
+ }): Promise<CustomerProductReviewCreated>;
208
+ submitReturnRequest(
209
+ orderId: string,
210
+ input?: CustomerReturnRequestInput,
211
+ ): Promise<CustomerReturnRequestCreated>;
212
+ contactCompany(
213
+ input: CustomerContactCompanyInput,
214
+ ): Promise<CustomerContactCompanyResponse>;
215
+ myReferralStats(): Promise<CustomerReferralStats>;
216
+ myReferralHistory(opts?: {
217
+ limit?: number;
218
+ }): Promise<CustomerReferralHistoryResponse>;
219
+ myReferralPayouts(): Promise<CustomerReferralPayoutsResponse>;
220
+ };
221
+
222
+ export type PortalCoreClient = {
223
+ /**
224
+ * `organizationId` pins every request to that org.
225
+ *
226
+ * Optional, and omitting it keeps the API key's own org — which is what the
227
+ * controller does by default. It exists because a customer can belong to
228
+ * SEVERAL orgs, and notification preferences are stored per
229
+ * (org, user): editing them for org B from a controller pinned to org A is
230
+ * otherwise impossible, so a customer could see their other orgs and change
231
+ * nothing about them.
232
+ */
233
+ customerPortal(organizationId?: string): {
234
+ withSession(sessionToken: string): PortalSessionClient;
235
+ };
236
+ };
237
+
238
+ export type PortalStatus = "idle" | "loading" | "ready" | "error";
239
+
240
+ /**
241
+ * What the portal renders. Collections that the API can actually list live at
242
+ * the top; everything else is a detail slot filled by an `open*` action,
243
+ * because the API has no list route for it.
244
+ */
245
+ export type PortalSnapshot = {
246
+ status: PortalStatus;
247
+
248
+ /** Session + branding, from `context()`. */
249
+ context: CustomerPortalContextResponse | null;
250
+ profile: CustomerPortalProfile | null;
251
+ /** Summary counts — open jobs, unpaid balance — not a list of invoices. */
252
+ work: CustomerWorkBundle | null;
253
+ /**
254
+ * Push devices, and the key needed to register a new one.
255
+ *
256
+ * Not per-org, unlike everything beside it: a push subscription identifies a
257
+ * BROWSER, and a customer of three orgs has one browser. Which org may push
258
+ * to it lives in that org's `notificationPreferences` instead — so muting
259
+ * one company here would be the wrong lever, and is not offered.
260
+ */
261
+ push: CustomerPushConfig | null;
262
+ notificationPreferences: NotificationPreferencesResponse | null;
263
+ /**
264
+ * Preferences for OTHER orgs this customer belongs to, keyed by org id.
265
+ *
266
+ * Separate from `notificationPreferences` (this org's) so a dashboard can
267
+ * render the current org without waiting on, or failing because of, orgs
268
+ * the customer has not expanded yet.
269
+ */
270
+ notificationPreferencesByOrg: Record<string, NotificationPreferencesResponse>;
271
+ otherOrgs: CustomerPortalOtherOrgsResponse | null;
272
+
273
+ /**
274
+ * The listable collections.
275
+ *
276
+ * Invoices, quotes, contracts and shipments joined this set in 0.9.61. Before
277
+ * that they were detail-by-id only, and `work` carried the counts with no way
278
+ * to enumerate what made them up.
279
+ */
280
+ orders: CustomerOrderListResponse | null;
281
+ payments: CustomerPaymentHistoryResponse | null;
282
+ reviews: CustomerReviewListResponse | null;
283
+ invoices: CustomerInvoiceListResponse | null;
284
+ quotes: CustomerQuoteListResponse | null;
285
+ contracts: CustomerContractListResponse | null;
286
+ shipments: CustomerShipmentListResponse | null;
287
+
288
+ /**
289
+ * The staff chat thread. Empty until `loadMessages()` — opening a thread
290
+ * nobody asked for spends a request on every page load.
291
+ */
292
+ messages: CustomerPortalMessageListResponse | null;
293
+ /** Staff messages not yet marked read. Drives an unread badge. */
294
+ unreadMessageCount: number;
295
+ /** Return requests on the OPEN order. Cleared with the rest of the detail. */
296
+ returnRequests: CustomerReturnRequestListResponse | null;
297
+ /** The customer's own product reviews. Null until `loadProductReviews`. */
298
+ productReviews: CustomerProductReviewList | null;
299
+ /**
300
+ * The subscription with this org, plus the org's offerings. Null until
301
+ * `loadSubscription`.
302
+ *
303
+ * Render entitlement from `subscription.subscription?.hasAccess`, never
304
+ * from `status` — a lifetime purchase never expires and a cancelled
305
+ * subscription keeps access until the paid period ends.
306
+ */
307
+ subscription: CustomerSubscriptionResponse | null;
308
+ /**
309
+ * Subscriber content. When `entitled` is false these are LOCKED previews,
310
+ * not an empty entitlement — show them beside the offer.
311
+ */
312
+ subscriberContent: CustomerSubscriberContentResponse | null;
313
+ /**
314
+ * Set after `submitProductReview` succeeds while the org holds it for
315
+ * approval. Render "awaiting review" — silence reads as a lost review.
316
+ */
317
+ productReviewAwaitingModeration: boolean;
318
+ /**
319
+ * The last `reportNotReceived` result. `contested` means the carrier says
320
+ * delivered and the customer says otherwise — worth saying out loud, since
321
+ * that is the case that needs a carrier claim rather than a reship.
322
+ */
323
+ lastNotReceived: CustomerNotReceivedResult | null;
324
+
325
+ referrals: {
326
+ stats: CustomerReferralStats | null;
327
+ history: CustomerReferralHistoryResponse | null;
328
+ payouts: CustomerReferralPayoutsResponse | null;
329
+ };
330
+
331
+ /** Detail slots — one at a time, filled by `open*`. */
332
+ activeJob: CustomerJobDetail | null;
333
+ jobActivity: CustomerJobActivityResponse | null;
334
+ jobEta: CustomerJobEtaResponse | null;
335
+ jobComments: CustomerJobCommentsListResponse | null;
336
+ activeOrder: CustomerOrderDetail | null;
337
+ orderDownloads: CustomerDownloadsResponse | null;
338
+ activeInvoice: CustomerInvoiceDetail | null;
339
+ activeQuote: CustomerQuoteDetail | null;
340
+ activeContract: CustomerContractDetail | null;
341
+ shipmentTracking: CustomerShipmentTracking | null;
342
+
343
+ /**
344
+ * Set after `submitReview` succeeds. The review is queued for staff
345
+ * approval and is NOT in `reviews` — surfacing this lets a portal say
346
+ * "thanks, it's awaiting review" rather than implying it is live.
347
+ */
348
+ reviewAwaitingModeration: boolean;
349
+
350
+ error: Error | null;
351
+ pending: {
352
+ overview: boolean;
353
+ orders: boolean;
354
+ payments: boolean;
355
+ reviews: boolean;
356
+ referrals: boolean;
357
+ detail: boolean;
358
+ action: boolean;
359
+ };
360
+ };
361
+
362
+ /**
363
+ * What happened when a page asked to turn push on.
364
+ *
365
+ * A discriminated union rather than a boolean because the ways this fails are
366
+ * not interchangeable: `denied` cannot be retried without the visitor digging
367
+ * through site settings, `dismissed` can be asked again tomorrow, and
368
+ * `ios-needs-home-screen` is fixed by the visitor in ten seconds if anyone
369
+ * tells them how. One boolean collapses all three into "didn't work".
370
+ */
371
+ export type PushEnableResult =
372
+ | { ok: true; deviceId: string }
373
+ | {
374
+ ok: false;
375
+ reason:
376
+ | "unsupported"
377
+ | "ios-needs-home-screen"
378
+ | "insecure-context"
379
+ | "denied"
380
+ | "dismissed"
381
+ | "not-configured"
382
+ | "failed";
383
+ message: string;
384
+ };
385
+
386
+ export type PortalController = {
387
+ snapshot(): PortalSnapshot;
388
+ subscribe(callback: (snapshot: PortalSnapshot) => void): () => void;
389
+
390
+ /** Context, profile and the work summary — the portal landing state. */
391
+ load(): Promise<void>;
392
+ loadOrders(opts?: { limit?: number }): Promise<void>;
393
+ /** `unpaid: true` is the "everything I owe" view. */
394
+ loadInvoices(opts?: {
395
+ limit?: number;
396
+ status?: string;
397
+ unpaid?: boolean;
398
+ }): Promise<void>;
399
+ loadQuotes(opts?: { limit?: number; status?: string }): Promise<void>;
400
+ loadContracts(opts?: { limit?: number; status?: string }): Promise<void>;
401
+ /** `active: true` narrows to parcels still moving. Stored status only. */
402
+ loadShipments(opts?: { limit?: number; active?: boolean }): Promise<void>;
403
+ /** The staff chat thread. `jobId` narrows it to one job's conversation. */
404
+ loadMessages(opts?: { limit?: number; jobId?: string }): Promise<void>;
405
+ /** Send a message to staff. The thread is re-read afterwards. */
406
+ postMessage(input: CustomerPortalMessagePostInput): Promise<boolean>;
407
+ /** Mark staff messages read. One-way — it never un-reads. */
408
+ markMessagesRead(): Promise<void>;
409
+ loadPayments(opts?: { limit?: number }): Promise<void>;
410
+ loadReviews(): Promise<void>;
411
+ loadReferrals(): Promise<void>;
412
+ /** Push config plus this browser's registration state. */
413
+ loadPushConfig(): Promise<void>;
414
+ /**
415
+ * Turn push on for THIS browser, end to end.
416
+ *
417
+ * Reads the key, registers the service worker, asks permission and records
418
+ * the subscription — the whole dance behind one call, because every step of
419
+ * it is plumbing and none of it is a design decision the consumer wants.
420
+ *
421
+ * **Call it from a click.** Permission is asked once per origin and a
422
+ * refusal is sticky, so a page that prompts on load spends the only chance
423
+ * it gets on someone who has not yet been told what they are agreeing to.
424
+ *
425
+ * The failure cases are returned rather than thrown, because they are not
426
+ * errors so much as answers — "blocked", "add to Home Screen first" — and
427
+ * each deserves different copy.
428
+ */
429
+ enablePush(options?: {
430
+ serviceWorkerPath?: string;
431
+ scope?: string;
432
+ label?: string;
433
+ }): Promise<PushEnableResult>;
434
+ /**
435
+ * Turn push off for this browser: tears the local subscription down AND
436
+ * forgets the row, so nothing is left pushing into a dead endpoint.
437
+ */
438
+ disablePush(): Promise<boolean>;
439
+ /** Rename a device, or narrow what it receives. */
440
+ updatePushDevice(input: {
441
+ id: string;
442
+ label?: string | null;
443
+ categories?: string[] | null;
444
+ }): Promise<boolean>;
445
+ /** Forget one device by id — for revoking a browser you no longer have. */
446
+ forgetPushDevice(id: string): Promise<boolean>;
447
+ loadNotificationPreferences(): Promise<void>;
448
+ /**
449
+ * Load notification preferences for ANOTHER org this customer belongs to.
450
+ * Pass an org id from `snapshot().otherOrgs`.
451
+ */
452
+ loadNotificationPreferencesFor(orgId: string): Promise<void>;
453
+ loadOtherOrgs(): Promise<void>;
454
+
455
+ /** Job detail plus its activity, ETA and comments, in one call. */
456
+ openJob(jobId: string): Promise<void>;
457
+ openOrder(orderId: string): Promise<void>;
458
+ openInvoice(invoiceId: string): Promise<void>;
459
+ /** Fetches the quote and marks it viewed, which the org can see. */
460
+ openQuote(quoteId: string): Promise<void>;
461
+ openContract(contractId: string): Promise<void>;
462
+ trackShipment(shipmentId: string): Promise<void>;
463
+ /** Clear every detail slot. */
464
+ closeDetail(): void;
465
+
466
+ postJobComment(
467
+ jobId: string,
468
+ input: CustomerJobCommentPostInput,
469
+ ): Promise<boolean>;
470
+ acceptQuote(quoteId: string): Promise<boolean>;
471
+ rejectQuote(quoteId: string): Promise<boolean>;
472
+ signContract(contractId: string): Promise<boolean>;
473
+ payInvoice(
474
+ invoiceId: string,
475
+ opts?: CustomerInvoicePayInput,
476
+ ): Promise<CustomerInvoicePayResponse | null>;
477
+ updateProfile(input: CustomerPortalUpdateProfileInput): Promise<boolean>;
478
+ updateNotificationPreferences(
479
+ input: NotificationPreferencesUpdateInput,
480
+ ): Promise<boolean>;
481
+ /** Update preferences for another of the customer's orgs. */
482
+ updateNotificationPreferencesFor(
483
+ orgId: string,
484
+ input: NotificationPreferencesUpdateInput,
485
+ ): Promise<boolean>;
486
+ /** Queues a review for staff approval. It will NOT appear in `reviews`. */
487
+ submitReview(input: CustomerReviewSubmitInput): Promise<boolean>;
488
+ submitReturnRequest(
489
+ orderId: string,
490
+ input?: CustomerReturnRequestInput,
491
+ ): Promise<CustomerReturnRequestCreated | null>;
492
+ /** Ask for a replacement instead of money back. */
493
+ submitExchangeRequest(
494
+ orderId: string,
495
+ input?: Omit<CustomerReturnRequestInput, "kind">,
496
+ ): Promise<CustomerReturnRequestCreated | null>;
497
+ /**
498
+ * Report a parcel as never arrived. Does NOT change carrier status — the
499
+ * result carries both the claim and the carrier's own verdict.
500
+ */
501
+ reportNotReceived(
502
+ shipmentId: string,
503
+ input?: { note?: string },
504
+ ): Promise<CustomerNotReceivedResult | null>;
505
+ loadProductReviews(): Promise<void>;
506
+ /**
507
+ * Dispatch status for a job the customer owns.
508
+ *
509
+ * Lands in `snapshot().jobEta`, which is the same surface `openJob` fills —
510
+ * one place for "where is my technician", not two that can disagree.
511
+ */
512
+ loadDispatchStatus(jobId: string): Promise<void>;
513
+ loadSubscription(): Promise<void>;
514
+ loadSubscriberContent(opts?: { limit?: number }): Promise<void>;
515
+ /** Ends the RENEWAL. Access continues until the paid period ends. */
516
+ cancelSubscription(): Promise<CustomerSubscriptionCancelResponse | null>;
517
+ /** Clears a pending cancellation. */
518
+ resumeSubscription(): Promise<CustomerSubscriptionCancelResponse | null>;
519
+ /** Verified-buyer review. Queued when the org moderates. */
520
+ submitProductReview(input: {
521
+ productId: string;
522
+ rating: number;
523
+ content: string;
524
+ }): Promise<boolean>;
525
+ contactCompany(
526
+ input: CustomerContactCompanyInput,
527
+ ): Promise<CustomerContactCompanyResponse | null>;
528
+
529
+ destroy(): void;
530
+ };
531
+
532
+ export type CreatePortalControllerOptions = {
533
+ client: PortalCoreClient;
534
+ /**
535
+ * The signed-in customer's session token. REQUIRED — every portal route
536
+ * needs it, and a portal built without one renders as empty rather than as
537
+ * signed-out.
538
+ */
539
+ sessionToken: string;
540
+ onError?: (error: Error) => void;
541
+ };
542
+
543
+ function toError(cause: unknown, fallback: string): Error {
544
+ return cause instanceof Error ? cause : new Error(fallback);
545
+ }
546
+
547
+ export function createPortalController(
548
+ options: CreatePortalControllerOptions,
549
+ ): PortalController {
550
+ const { onError } = options;
551
+ if (!options.sessionToken) {
552
+ throw new Error(
553
+ "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.",
554
+ );
555
+ }
556
+ const portal = options.client
557
+ .customerPortal()
558
+ .withSession(options.sessionToken);
559
+
560
+ /**
561
+ * A portal client pinned to another of the customer's orgs.
562
+ *
563
+ * Memoised because a dashboard listing five companies would otherwise build
564
+ * a client per render. The session token is the same — it identifies the
565
+ * PERSON; the org id only says which tenant's data to read, and the server
566
+ * still checks they belong to it.
567
+ */
568
+ const orgClients = new Map<string, PortalSessionClient>();
569
+ const portalFor = (orgId: string): PortalSessionClient => {
570
+ let pinned = orgClients.get(orgId);
571
+ if (!pinned) {
572
+ pinned = options.client
573
+ .customerPortal(orgId)
574
+ .withSession(options.sessionToken);
575
+ orgClients.set(orgId, pinned);
576
+ }
577
+ return pinned;
578
+ };
579
+
580
+ let destroyed = false;
581
+ /** Guards a slow detail fetch landing after the customer navigated on. */
582
+ let detailToken = 0;
583
+ const subscribers = new Set<(s: PortalSnapshot) => void>();
584
+
585
+ const state: {
586
+ status: PortalStatus;
587
+ context: CustomerPortalContextResponse | null;
588
+ profile: CustomerPortalProfile | null;
589
+ work: CustomerWorkBundle | null;
590
+ push: CustomerPushConfig | null;
591
+ notificationPreferences: NotificationPreferencesResponse | null;
592
+ notificationPreferencesByOrg: Record<
593
+ string,
594
+ NotificationPreferencesResponse
595
+ >;
596
+ otherOrgs: CustomerPortalOtherOrgsResponse | null;
597
+ orders: CustomerOrderListResponse | null;
598
+ payments: CustomerPaymentHistoryResponse | null;
599
+ reviews: CustomerReviewListResponse | null;
600
+ invoices: CustomerInvoiceListResponse | null;
601
+ quotes: CustomerQuoteListResponse | null;
602
+ contracts: CustomerContractListResponse | null;
603
+ shipments: CustomerShipmentListResponse | null;
604
+ messages: CustomerPortalMessageListResponse | null;
605
+ returnRequests: CustomerReturnRequestListResponse | null;
606
+ productReviews: CustomerProductReviewList | null;
607
+ subscription: CustomerSubscriptionResponse | null;
608
+ subscriberContent: CustomerSubscriberContentResponse | null;
609
+ productReviewAwaitingModeration: boolean;
610
+ lastNotReceived: CustomerNotReceivedResult | null;
611
+ referrals: PortalSnapshot["referrals"];
612
+ activeJob: CustomerJobDetail | null;
613
+ jobActivity: CustomerJobActivityResponse | null;
614
+ jobEta: CustomerJobEtaResponse | null;
615
+ jobComments: CustomerJobCommentsListResponse | null;
616
+ activeOrder: CustomerOrderDetail | null;
617
+ orderDownloads: CustomerDownloadsResponse | null;
618
+ activeInvoice: CustomerInvoiceDetail | null;
619
+ activeQuote: CustomerQuoteDetail | null;
620
+ activeContract: CustomerContractDetail | null;
621
+ shipmentTracking: CustomerShipmentTracking | null;
622
+ reviewAwaitingModeration: boolean;
623
+ error: Error | null;
624
+ pending: PortalSnapshot["pending"];
625
+ } = {
626
+ status: "idle",
627
+ context: null,
628
+ profile: null,
629
+ work: null,
630
+ push: null,
631
+ notificationPreferences: null,
632
+ notificationPreferencesByOrg: {},
633
+ otherOrgs: null,
634
+ orders: null,
635
+ payments: null,
636
+ reviews: null,
637
+ invoices: null,
638
+ quotes: null,
639
+ contracts: null,
640
+ shipments: null,
641
+ messages: null,
642
+ returnRequests: null,
643
+ productReviews: null,
644
+ subscription: null,
645
+ subscriberContent: null,
646
+ productReviewAwaitingModeration: false,
647
+ lastNotReceived: null,
648
+ referrals: { stats: null, history: null, payouts: null },
649
+ activeJob: null,
650
+ jobActivity: null,
651
+ jobEta: null,
652
+ jobComments: null,
653
+ activeOrder: null,
654
+ orderDownloads: null,
655
+ activeInvoice: null,
656
+ activeQuote: null,
657
+ activeContract: null,
658
+ shipmentTracking: null,
659
+ reviewAwaitingModeration: false,
660
+ error: null,
661
+ pending: {
662
+ overview: false,
663
+ orders: false,
664
+ payments: false,
665
+ reviews: false,
666
+ referrals: false,
667
+ detail: false,
668
+ action: false,
669
+ },
670
+ };
671
+
672
+ let cached: PortalSnapshot | null = null;
673
+
674
+ function snapshot(): PortalSnapshot {
675
+ if (!cached) {
676
+ cached = {
677
+ status: state.status,
678
+ context: state.context,
679
+ profile: state.profile,
680
+ work: state.work,
681
+ push: state.push,
682
+ notificationPreferences: state.notificationPreferences,
683
+ notificationPreferencesByOrg: state.notificationPreferencesByOrg,
684
+ otherOrgs: state.otherOrgs,
685
+ orders: state.orders,
686
+ payments: state.payments,
687
+ reviews: state.reviews,
688
+ invoices: state.invoices,
689
+ quotes: state.quotes,
690
+ contracts: state.contracts,
691
+ shipments: state.shipments,
692
+ messages: state.messages,
693
+ returnRequests: state.returnRequests,
694
+ productReviews: state.productReviews,
695
+ subscription: state.subscription,
696
+ subscriberContent: state.subscriberContent,
697
+ productReviewAwaitingModeration: state.productReviewAwaitingModeration,
698
+ lastNotReceived: state.lastNotReceived,
699
+ unreadMessageCount: (state.messages?.items ?? []).filter(
700
+ (m) => m.direction === "outbound" && m.readByCustomerAt === null,
701
+ ).length,
702
+ referrals: state.referrals,
703
+ activeJob: state.activeJob,
704
+ jobActivity: state.jobActivity,
705
+ jobEta: state.jobEta,
706
+ jobComments: state.jobComments,
707
+ activeOrder: state.activeOrder,
708
+ orderDownloads: state.orderDownloads,
709
+ activeInvoice: state.activeInvoice,
710
+ activeQuote: state.activeQuote,
711
+ activeContract: state.activeContract,
712
+ shipmentTracking: state.shipmentTracking,
713
+ reviewAwaitingModeration: state.reviewAwaitingModeration,
714
+ error: state.error,
715
+ pending: state.pending,
716
+ };
717
+ }
718
+ return cached;
719
+ }
720
+
721
+ function notify(): void {
722
+ if (destroyed) return;
723
+ cached = null;
724
+ const next = snapshot();
725
+ for (const cb of subscribers) cb(next);
726
+ }
727
+
728
+ function fail(cause: unknown, fallback: string): void {
729
+ const error = toError(cause, fallback);
730
+ state.error = error;
731
+ if (state.status !== "ready") state.status = "error";
732
+ onError?.(error);
733
+ }
734
+
735
+ /** Run a read that fills one slice of the snapshot. */
736
+ async function read(
737
+ key: keyof PortalSnapshot["pending"],
738
+ work: () => Promise<void>,
739
+ fallback: string,
740
+ ): Promise<void> {
741
+ if (destroyed) return;
742
+ state.pending[key] = true;
743
+ notify();
744
+ try {
745
+ await work();
746
+ if (!destroyed) state.status = "ready";
747
+ } catch (cause) {
748
+ if (!destroyed) fail(cause, fallback);
749
+ } finally {
750
+ if (!destroyed) {
751
+ state.pending[key] = false;
752
+ notify();
753
+ }
754
+ }
755
+ }
756
+
757
+ /** Run a write. Returns false rather than throwing, so a form can branch. */
758
+ async function act<T>(
759
+ work: () => Promise<T>,
760
+ fallback: string,
761
+ after?: (result: T) => void,
762
+ ): Promise<T | null> {
763
+ if (destroyed) return null;
764
+ state.pending.action = true;
765
+ state.error = null;
766
+ notify();
767
+ try {
768
+ const result = await work();
769
+ if (destroyed) return null;
770
+ after?.(result);
771
+ return result;
772
+ } catch (cause) {
773
+ if (!destroyed) fail(cause, fallback);
774
+ return null;
775
+ } finally {
776
+ if (!destroyed) {
777
+ state.pending.action = false;
778
+ notify();
779
+ }
780
+ }
781
+ }
782
+
783
+ /** Fill a detail slot, dropping the result if the customer moved on. */
784
+ async function detail(
785
+ work: (token: number) => Promise<void>,
786
+ fallback: string,
787
+ ): Promise<void> {
788
+ if (destroyed) return;
789
+ const token = ++detailToken;
790
+ state.pending.detail = true;
791
+ state.error = null;
792
+ notify();
793
+ try {
794
+ await work(token);
795
+ } catch (cause) {
796
+ if (!destroyed && token === detailToken) fail(cause, fallback);
797
+ } finally {
798
+ if (!destroyed && token === detailToken) {
799
+ state.pending.detail = false;
800
+ notify();
801
+ }
802
+ }
803
+ }
804
+
805
+ return {
806
+ snapshot,
807
+
808
+ subscribe(callback) {
809
+ subscribers.add(callback);
810
+ return () => {
811
+ subscribers.delete(callback);
812
+ };
813
+ },
814
+
815
+ load() {
816
+ if (state.status === "idle") state.status = "loading";
817
+ return read(
818
+ "overview",
819
+ async () => {
820
+ // Context, profile and work are independent, so one failing
821
+ // should not hide the other two.
822
+ const [context, profile, work] = await Promise.allSettled([
823
+ portal.context(),
824
+ portal.getProfile(),
825
+ portal.getWork(),
826
+ ]);
827
+ if (destroyed) return;
828
+ if (context.status === "fulfilled") state.context = context.value;
829
+ if (profile.status === "fulfilled") state.profile = profile.value;
830
+ if (work.status === "fulfilled") state.work = work.value;
831
+ const firstFailure = [context, profile, work].find(
832
+ (r) => r.status === "rejected",
833
+ );
834
+ if (firstFailure && firstFailure.status === "rejected") {
835
+ throw firstFailure.reason;
836
+ }
837
+ },
838
+ "Failed to load the portal.",
839
+ );
840
+ },
841
+
842
+ loadOrders(opts) {
843
+ return read(
844
+ "orders",
845
+ async () => {
846
+ state.orders = await portal.listOrders(opts);
847
+ },
848
+ "Failed to load orders.",
849
+ );
850
+ },
851
+
852
+ loadInvoices(opts) {
853
+ return read(
854
+ "orders",
855
+ async () => {
856
+ state.invoices = await portal.listInvoices(opts);
857
+ },
858
+ "Failed to load invoices.",
859
+ );
860
+ },
861
+
862
+ loadQuotes(opts) {
863
+ return read(
864
+ "orders",
865
+ async () => {
866
+ state.quotes = await portal.listQuotes(opts);
867
+ },
868
+ "Failed to load quotes.",
869
+ );
870
+ },
871
+
872
+ loadContracts(opts) {
873
+ return read(
874
+ "orders",
875
+ async () => {
876
+ state.contracts = await portal.listContracts(opts);
877
+ },
878
+ "Failed to load contracts.",
879
+ );
880
+ },
881
+
882
+ loadShipments(opts) {
883
+ return read(
884
+ "orders",
885
+ async () => {
886
+ state.shipments = await portal.listShipments(opts);
887
+ },
888
+ "Failed to load shipments.",
889
+ );
890
+ },
891
+
892
+ loadMessages(opts) {
893
+ return read(
894
+ "orders",
895
+ async () => {
896
+ state.messages = await portal.listMessages(opts);
897
+ },
898
+ "Failed to load your messages.",
899
+ );
900
+ },
901
+
902
+ async postMessage(input) {
903
+ const res = await act(
904
+ () => portal.postMessage(input),
905
+ "Failed to send your message.",
906
+ );
907
+ if (!res) return false;
908
+ // Re-read rather than append: the stored row carries the author name
909
+ // and timestamp the server assigned, and a locally-appended message
910
+ // that later differs is the kind of thing a customer notices.
911
+ await read(
912
+ "orders",
913
+ async () => {
914
+ state.messages = await portal.listMessages(
915
+ input.jobId ? { jobId: input.jobId } : undefined,
916
+ );
917
+ },
918
+ "Message sent, but the thread could not be refreshed.",
919
+ );
920
+ return true;
921
+ },
922
+
923
+ async markMessagesRead() {
924
+ await act(
925
+ () => portal.markMessagesRead(),
926
+ "Failed to mark your messages read.",
927
+ () => {
928
+ // Stamp locally so the badge clears immediately rather than
929
+ // waiting for a re-read the caller may never do.
930
+ if (state.messages) {
931
+ const now = new Date().toISOString();
932
+ state.messages = {
933
+ ...state.messages,
934
+ items: state.messages.items.map((m) =>
935
+ m.direction === "outbound" && m.readByCustomerAt === null
936
+ ? { ...m, readByCustomerAt: now }
937
+ : m,
938
+ ),
939
+ };
940
+ }
941
+ },
942
+ );
943
+ },
944
+
945
+ loadPayments(opts) {
946
+ return read(
947
+ "payments",
948
+ async () => {
949
+ state.payments = await portal.listPayments(opts);
950
+ },
951
+ "Failed to load payment history.",
952
+ );
953
+ },
954
+
955
+ loadReviews() {
956
+ return read(
957
+ "reviews",
958
+ async () => {
959
+ state.reviews = await portal.listMyReviews();
960
+ },
961
+ "Failed to load your reviews.",
962
+ );
963
+ },
964
+
965
+ loadReferrals() {
966
+ return read(
967
+ "referrals",
968
+ async () => {
969
+ const [stats, history, payouts] = await Promise.all([
970
+ portal.myReferralStats(),
971
+ portal.myReferralHistory(),
972
+ portal.myReferralPayouts(),
973
+ ]);
974
+ if (destroyed) return;
975
+ state.referrals = { stats, history, payouts };
976
+ },
977
+ "Failed to load referrals.",
978
+ );
979
+ },
980
+
981
+ loadPushConfig() {
982
+ return read(
983
+ // "overview" rather than a slice of its own: the device list is
984
+ // small, loads with the rest of a settings page, and one more
985
+ // pending flag is one more thing every binding has to thread.
986
+ "overview",
987
+ async () => {
988
+ state.push = await portal.getPushConfig();
989
+ },
990
+ "Failed to load your notification devices.",
991
+ );
992
+ },
993
+
994
+ async enablePush(options) {
995
+ // Support is checked BEFORE the config fetch: on an iPhone in a
996
+ // normal tab there is nothing a key could enable, and a round trip
997
+ // that ends in "add this to your Home Screen" is a wasted one.
998
+ const support = pushSupport();
999
+ if (!support.supported) {
1000
+ return {
1001
+ ok: false,
1002
+ reason: support.reason === "server" ? "unsupported" : support.reason,
1003
+ message: support.message,
1004
+ };
1005
+ }
1006
+
1007
+ const config = await act(
1008
+ () => portal.getPushConfig(),
1009
+ "Failed to start setting up notifications.",
1010
+ );
1011
+ if (!config) {
1012
+ return {
1013
+ ok: false,
1014
+ reason: "failed",
1015
+ message: "Failed to start setting up notifications.",
1016
+ };
1017
+ }
1018
+ if (!config.enabled || !config.vapidPublicKey) {
1019
+ // The platform has no VAPID pair configured. Nothing the visitor
1020
+ // or the org can do about it, so say so plainly instead of
1021
+ // failing at `subscribe()` with a DOMException nobody can act on.
1022
+ return {
1023
+ ok: false,
1024
+ reason: "not-configured",
1025
+ message: "Push notifications aren't available for this site yet.",
1026
+ };
1027
+ }
1028
+
1029
+ const subscribed = await subscribeToPush({
1030
+ vapidPublicKey: config.vapidPublicKey,
1031
+ ...(options?.serviceWorkerPath === undefined
1032
+ ? {}
1033
+ : { serviceWorkerPath: options.serviceWorkerPath }),
1034
+ ...(options?.scope === undefined ? {} : { scope: options.scope }),
1035
+ });
1036
+ if (!subscribed.ok) {
1037
+ return {
1038
+ ok: false,
1039
+ reason: subscribed.reason,
1040
+ message: subscribed.message,
1041
+ };
1042
+ }
1043
+
1044
+ const saved = await act(
1045
+ () =>
1046
+ portal.subscribePush({
1047
+ endpoint: subscribed.subscription.endpoint,
1048
+ p256dh: subscribed.subscription.p256dh,
1049
+ auth: subscribed.subscription.auth,
1050
+ userAgent: subscribed.subscription.userAgent ?? null,
1051
+ label: options?.label ?? null,
1052
+ }),
1053
+ "Notifications were allowed, but we couldn't finish setting them up.",
1054
+ );
1055
+ if (!saved) {
1056
+ // The browser now holds a subscription the server does not know
1057
+ // about. Roll it back rather than leaving a device that looks
1058
+ // enabled locally and receives nothing — a state whose only
1059
+ // symptom is silence.
1060
+ await unsubscribeFromPush().catch(() => undefined);
1061
+ return {
1062
+ ok: false,
1063
+ reason: "failed",
1064
+ message:
1065
+ "Notifications were allowed, but we couldn't finish setting them up. Try again.",
1066
+ };
1067
+ }
1068
+
1069
+ await this.loadPushConfig();
1070
+ return { ok: true, deviceId: saved.id };
1071
+ },
1072
+
1073
+ async disablePush() {
1074
+ // Read the endpoint BEFORE tearing the local subscription down —
1075
+ // it is what the server keys on, and once `unsubscribe()` has run
1076
+ // there is nothing left to look the row up by.
1077
+ const local = await currentPushSubscription().catch(() => null);
1078
+ const torn = await unsubscribeFromPush().catch(() => ({
1079
+ endpoint: null,
1080
+ }));
1081
+ const endpoint = torn.endpoint ?? local?.endpoint ?? null;
1082
+ if (!endpoint) {
1083
+ // Nothing registered here. Refresh anyway, since a device may
1084
+ // have been removed from somewhere else.
1085
+ await this.loadPushConfig();
1086
+ return true;
1087
+ }
1088
+ const result = await act(
1089
+ () => portal.unsubscribePush({ endpoint }),
1090
+ "Failed to turn notifications off.",
1091
+ );
1092
+ if (result) await this.loadPushConfig();
1093
+ return Boolean(result?.ok);
1094
+ },
1095
+
1096
+ async updatePushDevice(input) {
1097
+ const result = await act(
1098
+ () => portal.updatePushDevice(input),
1099
+ "Failed to update that device.",
1100
+ );
1101
+ if (result?.ok) await this.loadPushConfig();
1102
+ return Boolean(result?.ok);
1103
+ },
1104
+
1105
+ async forgetPushDevice(id) {
1106
+ const result = await act(
1107
+ () => portal.unsubscribePush({ id }),
1108
+ "Failed to remove that device.",
1109
+ );
1110
+ if (result?.ok) await this.loadPushConfig();
1111
+ return Boolean(result?.ok);
1112
+ },
1113
+
1114
+ loadNotificationPreferences() {
1115
+ return read(
1116
+ "overview",
1117
+ async () => {
1118
+ state.notificationPreferences =
1119
+ await portal.getNotificationPreferences();
1120
+ },
1121
+ "Failed to load notification preferences.",
1122
+ );
1123
+ },
1124
+
1125
+ loadOtherOrgs() {
1126
+ return read(
1127
+ "overview",
1128
+ async () => {
1129
+ state.otherOrgs = await portal.myOtherCustomerOrgs();
1130
+ },
1131
+ "Failed to load your other companies.",
1132
+ );
1133
+ },
1134
+
1135
+ loadNotificationPreferencesFor(orgId) {
1136
+ return read(
1137
+ "overview",
1138
+ async () => {
1139
+ const prefs = await portalFor(orgId).getNotificationPreferences();
1140
+ // Replace the whole record rather than mutating in place, so
1141
+ // framework bindings comparing snapshot identity actually see it.
1142
+ state.notificationPreferencesByOrg = {
1143
+ ...state.notificationPreferencesByOrg,
1144
+ [orgId]: prefs,
1145
+ };
1146
+ },
1147
+ "Failed to load notification preferences for that company.",
1148
+ );
1149
+ },
1150
+
1151
+ async updateNotificationPreferencesFor(orgId, input) {
1152
+ const res = await act(
1153
+ () => portalFor(orgId).updateNotificationPreferences(input),
1154
+ "Failed to update notification preferences for that company.",
1155
+ );
1156
+ if (res && !destroyed) {
1157
+ // The server merges sparse writes into the stored matrix, so the
1158
+ // authoritative result is what came BACK — echoing the request
1159
+ // would show the customer a matrix the server never agreed to.
1160
+ state.notificationPreferencesByOrg = {
1161
+ ...state.notificationPreferencesByOrg,
1162
+ [orgId]: res,
1163
+ };
1164
+ notify();
1165
+ }
1166
+ return res !== null;
1167
+ },
1168
+
1169
+ openJob(jobId) {
1170
+ return detail(async (token) => {
1171
+ const job = await portal.getJob(jobId);
1172
+ if (destroyed || token !== detailToken) return;
1173
+ state.activeJob = job;
1174
+ notify();
1175
+
1176
+ // Activity, ETA and comments are supporting detail. Any of them
1177
+ // failing must not blank the job the customer asked for.
1178
+ const [activity, eta, comments] = await Promise.allSettled([
1179
+ portal.getJobActivity(jobId),
1180
+ portal.getJobEta(jobId),
1181
+ portal.listJobComments(jobId),
1182
+ ]);
1183
+ if (destroyed || token !== detailToken) return;
1184
+ state.jobActivity =
1185
+ activity.status === "fulfilled" ? activity.value : null;
1186
+ state.jobEta = eta.status === "fulfilled" ? eta.value : null;
1187
+ state.jobComments =
1188
+ comments.status === "fulfilled" ? comments.value : null;
1189
+ }, "Failed to load the job.");
1190
+ },
1191
+
1192
+ openOrder(orderId) {
1193
+ return detail(async (token) => {
1194
+ const order = await portal.getOrder(orderId);
1195
+ if (destroyed || token !== detailToken) return;
1196
+ state.activeOrder = order;
1197
+ notify();
1198
+
1199
+ // Digital downloads only exist for some orders; a 404 here is
1200
+ // normal and must not look like the order failed to load.
1201
+ // Downloads and returns are both optional detail on an order —
1202
+ // most orders have neither, so a 404 on either is normal and must
1203
+ // not look like the order failed to load.
1204
+ const [downloads, returns] = await Promise.allSettled([
1205
+ portal.getDownloads(orderId),
1206
+ portal.listReturnRequests(orderId),
1207
+ ]);
1208
+ if (!destroyed && token === detailToken) {
1209
+ state.orderDownloads =
1210
+ downloads.status === "fulfilled" ? downloads.value : null;
1211
+ state.returnRequests =
1212
+ returns.status === "fulfilled" ? returns.value : null;
1213
+ }
1214
+ }, "Failed to load the order.");
1215
+ },
1216
+
1217
+ openInvoice(invoiceId) {
1218
+ return detail(async (token) => {
1219
+ const invoice = await portal.getInvoice(invoiceId);
1220
+ if (destroyed || token !== detailToken) return;
1221
+ state.activeInvoice = invoice;
1222
+ }, "Failed to load the invoice.");
1223
+ },
1224
+
1225
+ openQuote(quoteId) {
1226
+ return detail(async (token) => {
1227
+ // `viewQuote` both fetches and records that the customer looked,
1228
+ // which the org can see. That is the right call from a portal:
1229
+ // opening a quote IS viewing it.
1230
+ const quote = await portal.viewQuote(quoteId);
1231
+ if (destroyed || token !== detailToken) return;
1232
+ state.activeQuote = quote;
1233
+ }, "Failed to load the quote.");
1234
+ },
1235
+
1236
+ openContract(contractId) {
1237
+ return detail(async (token) => {
1238
+ const contract = await portal.getContract(contractId);
1239
+ if (destroyed || token !== detailToken) return;
1240
+ state.activeContract = contract;
1241
+ }, "Failed to load the contract.");
1242
+ },
1243
+
1244
+ trackShipment(shipmentId) {
1245
+ return detail(async (token) => {
1246
+ const tracking = await portal.trackShipment(shipmentId);
1247
+ if (destroyed || token !== detailToken) return;
1248
+ state.shipmentTracking = tracking;
1249
+ }, "Failed to track the shipment.");
1250
+ },
1251
+
1252
+ closeDetail() {
1253
+ if (destroyed) return;
1254
+ detailToken += 1; // orphan anything in flight
1255
+ state.activeJob = null;
1256
+ state.jobActivity = null;
1257
+ state.jobEta = null;
1258
+ state.jobComments = null;
1259
+ state.activeOrder = null;
1260
+ state.orderDownloads = null;
1261
+ state.returnRequests = null;
1262
+ state.activeInvoice = null;
1263
+ state.activeQuote = null;
1264
+ state.activeContract = null;
1265
+ state.shipmentTracking = null;
1266
+ // Per-shipment, so it belongs to the detail view rather than the
1267
+ // session — leaving it would show a stale claim on the next parcel.
1268
+ state.lastNotReceived = null;
1269
+ notify();
1270
+ },
1271
+
1272
+ async postJobComment(jobId, input) {
1273
+ const res = await act(
1274
+ () => portal.postJobComment(jobId, input),
1275
+ "Failed to post the comment.",
1276
+ );
1277
+ if (!res) return false;
1278
+ // Re-read rather than splice: the thread is the authority on what a
1279
+ // comment looks like once the server has it.
1280
+ await read(
1281
+ "detail",
1282
+ async () => {
1283
+ state.jobComments = await portal.listJobComments(jobId);
1284
+ },
1285
+ "Comment posted, but the thread could not be refreshed.",
1286
+ );
1287
+ return true;
1288
+ },
1289
+
1290
+ async acceptQuote(quoteId) {
1291
+ const res = await act(
1292
+ () => portal.acceptQuote(quoteId),
1293
+ "Failed to accept the quote.",
1294
+ () => {
1295
+ // One-way state change; re-read so the UI shows the new status
1296
+ // rather than the stale one it was rendered from.
1297
+ void portal
1298
+ .getQuote(quoteId)
1299
+ .then((q) => {
1300
+ if (!destroyed) {
1301
+ state.activeQuote = q;
1302
+ notify();
1303
+ }
1304
+ })
1305
+ .catch(() => {});
1306
+ },
1307
+ );
1308
+ return res !== null;
1309
+ },
1310
+
1311
+ async rejectQuote(quoteId) {
1312
+ const res = await act(
1313
+ () => portal.rejectQuote(quoteId),
1314
+ "Failed to reject the quote.",
1315
+ () => {
1316
+ void portal
1317
+ .getQuote(quoteId)
1318
+ .then((q) => {
1319
+ if (!destroyed) {
1320
+ state.activeQuote = q;
1321
+ notify();
1322
+ }
1323
+ })
1324
+ .catch(() => {});
1325
+ },
1326
+ );
1327
+ return res !== null;
1328
+ },
1329
+
1330
+ async signContract(contractId) {
1331
+ const res = await act(
1332
+ () => portal.signContract(contractId),
1333
+ "Failed to sign the contract.",
1334
+ () => {
1335
+ // There is no unsign. Re-reading is the only way the UI learns
1336
+ // the contract is now signed.
1337
+ void portal
1338
+ .getContract(contractId)
1339
+ .then((c) => {
1340
+ if (!destroyed) {
1341
+ state.activeContract = c;
1342
+ notify();
1343
+ }
1344
+ })
1345
+ .catch(() => {});
1346
+ },
1347
+ );
1348
+ return res !== null;
1349
+ },
1350
+
1351
+ payInvoice(invoiceId, opts) {
1352
+ return act(
1353
+ () => portal.payInvoice(invoiceId, opts),
1354
+ "Could not start the payment.",
1355
+ );
1356
+ },
1357
+
1358
+ async updateProfile(input) {
1359
+ const res = await act(
1360
+ () => portal.updateProfile(input),
1361
+ "Failed to save your details.",
1362
+ );
1363
+ if (res === null) return false;
1364
+ await read(
1365
+ "overview",
1366
+ async () => {
1367
+ state.profile = await portal.getProfile();
1368
+ },
1369
+ "Saved, but your details could not be re-read.",
1370
+ );
1371
+ return true;
1372
+ },
1373
+
1374
+ async updateNotificationPreferences(input) {
1375
+ const res = await act(
1376
+ () => portal.updateNotificationPreferences(input),
1377
+ "Failed to save your notification settings.",
1378
+ (next) => {
1379
+ state.notificationPreferences = next;
1380
+ },
1381
+ );
1382
+ return res !== null;
1383
+ },
1384
+
1385
+ async submitReview(input) {
1386
+ const res = await act(
1387
+ () => portal.submitReview(input),
1388
+ "Failed to submit your review.",
1389
+ () => {
1390
+ // Deliberately NOT added to `reviews`. It lands as `pending` for
1391
+ // staff approval, and showing it back would tell the author it is
1392
+ // live when the public cannot see it.
1393
+ state.reviewAwaitingModeration = true;
1394
+ },
1395
+ );
1396
+ return res !== null;
1397
+ },
1398
+
1399
+ submitReturnRequest(orderId, input) {
1400
+ return act(
1401
+ () => portal.submitReturnRequest(orderId, input),
1402
+ "Failed to submit the return request.",
1403
+ () => {
1404
+ // Re-read so the customer immediately sees the request they just
1405
+ // made, with its real status. Submitting into silence is what
1406
+ // made this gap worth closing.
1407
+ void portal
1408
+ .listReturnRequests(orderId)
1409
+ .then((res) => {
1410
+ if (!destroyed) {
1411
+ state.returnRequests = res;
1412
+ notify();
1413
+ }
1414
+ })
1415
+ .catch(() => {});
1416
+ },
1417
+ );
1418
+ },
1419
+
1420
+ submitExchangeRequest(orderId, input) {
1421
+ // Routed through the same method so the re-read, the error copy and
1422
+ // the pending flag stay identical — an exchange is a return request
1423
+ // with a different ask, not a separate flow.
1424
+ return this.submitReturnRequest(orderId, {
1425
+ ...(input ?? {}),
1426
+ kind: "exchange",
1427
+ });
1428
+ },
1429
+
1430
+ reportNotReceived(shipmentId, input) {
1431
+ return act(
1432
+ () => portal.reportNotReceived(shipmentId, input),
1433
+ "Failed to report the parcel as not received.",
1434
+ ).then((res) => {
1435
+ if (res && !destroyed) {
1436
+ state.lastNotReceived = res;
1437
+ notify();
1438
+ }
1439
+ return res;
1440
+ });
1441
+ },
1442
+
1443
+ loadDispatchStatus(jobId) {
1444
+ return detail(async (token) => {
1445
+ const eta = await portal.getDispatchStatus(jobId);
1446
+ if (destroyed || token !== detailToken) return;
1447
+ state.jobEta = eta;
1448
+ }, "Failed to load the dispatch status.");
1449
+ },
1450
+
1451
+ loadSubscription() {
1452
+ return read(
1453
+ "overview",
1454
+ async () => {
1455
+ state.subscription = await portal.getSubscription();
1456
+ },
1457
+ "Failed to load your subscription.",
1458
+ );
1459
+ },
1460
+
1461
+ loadSubscriberContent(opts) {
1462
+ return read(
1463
+ "overview",
1464
+ async () => {
1465
+ state.subscriberContent = await portal.getSubscriberContent(opts);
1466
+ },
1467
+ "Failed to load your subscriber content.",
1468
+ );
1469
+ },
1470
+
1471
+ async cancelSubscription() {
1472
+ const res = await act(
1473
+ () => portal.cancelSubscription(),
1474
+ "Failed to cancel your subscription.",
1475
+ );
1476
+ if (res && !destroyed) {
1477
+ // Re-read rather than patching the cached row: the provider's
1478
+ // webhook owns `status`, and guessing what it will write is how a
1479
+ // portal ends up showing "cancelled" while the customer still has
1480
+ // — and has paid for — access.
1481
+ void portal
1482
+ .getSubscription()
1483
+ .then((next) => {
1484
+ if (!destroyed) {
1485
+ state.subscription = next;
1486
+ notify();
1487
+ }
1488
+ })
1489
+ .catch(() => {});
1490
+ }
1491
+ return res;
1492
+ },
1493
+
1494
+ async resumeSubscription() {
1495
+ const res = await act(
1496
+ () => portal.resumeSubscription(),
1497
+ "Failed to resume your subscription.",
1498
+ );
1499
+ if (res && !destroyed) {
1500
+ void portal
1501
+ .getSubscription()
1502
+ .then((next) => {
1503
+ if (!destroyed) {
1504
+ state.subscription = next;
1505
+ notify();
1506
+ }
1507
+ })
1508
+ .catch(() => {});
1509
+ }
1510
+ return res;
1511
+ },
1512
+
1513
+ async loadProductReviews() {
1514
+ await act(
1515
+ () => portal.listProductReviews(),
1516
+ "Failed to load your product reviews.",
1517
+ () => {},
1518
+ ).then((res) => {
1519
+ if (res && !destroyed) {
1520
+ state.productReviews = res;
1521
+ notify();
1522
+ }
1523
+ });
1524
+ },
1525
+
1526
+ async submitProductReview(input) {
1527
+ const res = await act(
1528
+ () => portal.submitProductReview(input),
1529
+ "Failed to submit your review.",
1530
+ () => {},
1531
+ );
1532
+ if (res && !destroyed) {
1533
+ // Only flag moderation when the org actually held it. Saying
1534
+ // "awaiting review" for a review that is already live is its own
1535
+ // kind of wrong.
1536
+ state.productReviewAwaitingModeration = res.awaitingModeration;
1537
+ // Re-read so the customer sees what they wrote, with its real
1538
+ // approval state, rather than having to trust that it landed.
1539
+ void portal
1540
+ .listProductReviews()
1541
+ .then((list) => {
1542
+ if (!destroyed) {
1543
+ state.productReviews = list;
1544
+ notify();
1545
+ }
1546
+ })
1547
+ .catch(() => {});
1548
+ }
1549
+ return res !== null;
1550
+ },
1551
+
1552
+ contactCompany(input) {
1553
+ return act(
1554
+ () => portal.contactCompany(input),
1555
+ "Failed to send your message.",
1556
+ );
1557
+ },
1558
+
1559
+ destroy() {
1560
+ destroyed = true;
1561
+ subscribers.clear();
1562
+ },
1563
+ };
1564
+ }