@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.
- package/README.md +1012 -186
- package/assets/biab-sw.js +92 -0
- package/dist/account-core/index.d.ts +126 -0
- package/dist/account-core/index.d.ts.map +1 -0
- package/dist/account-core/index.js +191 -0
- package/dist/account-core/index.js.map +1 -0
- package/dist/account.d.ts +11 -0
- package/dist/account.d.ts.map +1 -0
- package/dist/account.js +11 -0
- package/dist/account.js.map +1 -0
- package/dist/auth-handler.d.ts +1 -1
- package/dist/auth-links.d.ts +33 -0
- package/dist/auth-links.d.ts.map +1 -0
- package/dist/auth-links.js +64 -0
- package/dist/auth-links.js.map +1 -0
- package/dist/blog-core/index.d.ts +112 -0
- package/dist/blog-core/index.d.ts.map +1 -0
- package/dist/blog-core/index.js +330 -0
- package/dist/blog-core/index.js.map +1 -0
- package/dist/blog.d.ts +11 -0
- package/dist/blog.d.ts.map +1 -0
- package/dist/blog.js +11 -0
- package/dist/blog.js.map +1 -0
- package/dist/chat-core/index.d.ts +97 -0
- package/dist/chat-core/index.d.ts.map +1 -0
- package/dist/chat-core/index.js +237 -0
- package/dist/chat-core/index.js.map +1 -0
- package/dist/chat.d.ts +11 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +11 -0
- package/dist/chat.js.map +1 -0
- package/dist/checkout-core/index.d.ts +65 -0
- package/dist/checkout-core/index.d.ts.map +1 -0
- package/dist/checkout-core/index.js +159 -0
- package/dist/checkout-core/index.js.map +1 -0
- package/dist/checkout.d.ts +10 -0
- package/dist/checkout.d.ts.map +1 -0
- package/dist/checkout.js +10 -0
- package/dist/checkout.js.map +1 -0
- package/dist/cli.js +133 -25
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +567 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +807 -1
- package/dist/client.js.map +1 -1
- package/dist/contracts.d.ts +3309 -761
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +541 -4
- package/dist/contracts.js.map +1 -1
- package/dist/data-model-schema.d.ts +1 -1
- package/dist/data-model-schema.d.ts.map +1 -1
- package/dist/element.d.ts +30 -0
- package/dist/element.d.ts.map +1 -1
- package/dist/element.js +56 -0
- package/dist/element.js.map +1 -1
- package/dist/embed-protocol.d.ts +86 -0
- package/dist/embed-protocol.d.ts.map +1 -0
- package/dist/embed-protocol.js +60 -0
- package/dist/embed-protocol.js.map +1 -0
- package/dist/framework-support.d.ts +122 -0
- package/dist/framework-support.d.ts.map +1 -0
- package/dist/framework-support.js +323 -0
- package/dist/framework-support.js.map +1 -0
- package/dist/index.cjs +1244 -5
- package/dist/legal-core/index.cjs +115 -0
- package/dist/legal-core/index.d.ts +160 -0
- package/dist/legal-core/index.d.ts.map +1 -0
- package/dist/legal-core/index.js +220 -0
- package/dist/legal-core/index.js.map +1 -0
- package/dist/marketing-core/index.d.ts +66 -0
- package/dist/marketing-core/index.d.ts.map +1 -0
- package/dist/marketing-core/index.js +154 -0
- package/dist/marketing-core/index.js.map +1 -0
- package/dist/marketing.d.ts +7 -0
- package/dist/marketing.d.ts.map +1 -0
- package/dist/marketing.js +7 -0
- package/dist/marketing.js.map +1 -0
- package/dist/mcp-adapters/index.d.ts +135 -0
- package/dist/mcp-adapters/index.d.ts.map +1 -0
- package/dist/mcp-adapters/index.js +187 -0
- package/dist/mcp-adapters/index.js.map +1 -0
- package/dist/mcp-adapters.d.ts +7 -0
- package/dist/mcp-adapters.d.ts.map +1 -0
- package/dist/mcp-adapters.js +7 -0
- package/dist/mcp-adapters.js.map +1 -0
- package/dist/model3d-core/index.cjs +95 -0
- package/dist/model3d-core/index.d.ts +167 -0
- package/dist/model3d-core/index.d.ts.map +1 -0
- package/dist/model3d-core/index.js +158 -0
- package/dist/model3d-core/index.js.map +1 -0
- package/dist/notifications-core/index.cjs +315 -0
- package/dist/notifications-core/index.d.ts +229 -0
- package/dist/notifications-core/index.d.ts.map +1 -0
- package/dist/notifications-core/index.js +445 -0
- package/dist/notifications-core/index.js.map +1 -0
- package/dist/portal-core/index.d.ts +451 -0
- package/dist/portal-core/index.d.ts.map +1 -0
- package/dist/portal-core/index.js +819 -0
- package/dist/portal-core/index.js.map +1 -0
- package/dist/portal.d.ts +7 -0
- package/dist/portal.d.ts.map +1 -0
- package/dist/portal.js +7 -0
- package/dist/portal.js.map +1 -0
- package/dist/push-core/index.cjs +171 -0
- package/dist/push-core/index.d.ts +149 -0
- package/dist/push-core/index.d.ts.map +1 -0
- package/dist/push-core/index.js +231 -0
- package/dist/push-core/index.js.map +1 -0
- package/dist/react-marketing.d.ts +1 -17
- package/dist/react-marketing.d.ts.map +1 -1
- package/dist/react-marketing.js +4 -0
- package/dist/react-marketing.js.map +1 -1
- package/dist/react.d.ts +294 -101
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +302 -246
- package/dist/react.js.map +1 -1
- package/dist/seed-core/index.d.ts +354 -0
- package/dist/seed-core/index.d.ts.map +1 -0
- package/dist/seed-core/index.js +420 -0
- package/dist/seed-core/index.js.map +1 -0
- package/dist/seed.d.ts +7 -0
- package/dist/seed.d.ts.map +1 -0
- package/dist/seed.js +7 -0
- package/dist/seed.js.map +1 -0
- package/dist/seo-core/blog.d.ts +94 -0
- package/dist/seo-core/blog.d.ts.map +1 -0
- package/dist/seo-core/blog.js +149 -0
- package/dist/seo-core/blog.js.map +1 -0
- package/dist/seo-core/commerce.d.ts +64 -0
- package/dist/seo-core/commerce.d.ts.map +1 -0
- package/dist/seo-core/commerce.js +87 -0
- package/dist/seo-core/commerce.js.map +1 -0
- package/dist/seo-core/index.cjs +751 -0
- package/dist/seo-core/index.d.ts +226 -0
- package/dist/seo-core/index.d.ts.map +1 -0
- package/dist/seo-core/index.js +455 -0
- package/dist/seo-core/index.js.map +1 -0
- package/dist/seo-core/local.d.ts +181 -0
- package/dist/seo-core/local.d.ts.map +1 -0
- package/dist/seo-core/local.js +193 -0
- package/dist/seo-core/local.js.map +1 -0
- package/dist/sitemap-core/index.cjs +167 -0
- package/dist/sitemap-core/index.d.ts +155 -0
- package/dist/sitemap-core/index.d.ts.map +1 -0
- package/dist/sitemap-core/index.js +195 -0
- package/dist/sitemap-core/index.js.map +1 -0
- package/dist/social-core/index.d.ts +74 -0
- package/dist/social-core/index.d.ts.map +1 -0
- package/dist/social-core/index.js +72 -0
- package/dist/social-core/index.js.map +1 -0
- package/dist/social.d.ts +7 -0
- package/dist/social.d.ts.map +1 -0
- package/dist/social.js +7 -0
- package/dist/social.js.map +1 -0
- package/dist/solid-compat.d.ts +204 -0
- package/dist/solid-compat.d.ts.map +1 -0
- package/dist/solid-compat.js +221 -0
- package/dist/solid-compat.js.map +1 -0
- package/dist/store-core/index.d.ts +141 -0
- package/dist/store-core/index.d.ts.map +1 -0
- package/dist/store-core/index.js +277 -0
- package/dist/store-core/index.js.map +1 -0
- package/dist/store.d.ts +12 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +12 -0
- package/dist/store.js.map +1 -0
- package/dist/vanilla.d.ts +111 -0
- package/dist/vanilla.d.ts.map +1 -1
- package/dist/vanilla.js +206 -0
- package/dist/vanilla.js.map +1 -1
- package/dist/verify-core/index.d.ts +98 -0
- package/dist/verify-core/index.d.ts.map +1 -0
- package/dist/verify-core/index.js +186 -0
- package/dist/verify-core/index.js.map +1 -0
- package/dist/verify.d.ts +8 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +8 -0
- package/dist/verify.js.map +1 -0
- package/openapi.json +4316 -1313
- package/package.json +121 -9
- package/src/account-core/index.ts +309 -0
- package/src/account.ts +11 -0
- package/src/angular/headless.ts +519 -0
- package/src/angular/index.ts +3 -0
- package/src/auth-links.ts +76 -0
- package/src/blog-core/index.ts +423 -0
- package/src/blog.ts +11 -0
- package/src/chat-core/index.ts +335 -0
- package/src/chat.ts +11 -0
- package/src/checkout-core/index.ts +225 -0
- package/src/checkout.ts +10 -0
- package/src/cli.ts +184 -28
- package/src/client.ts +998 -0
- package/src/contracts.ts +730 -11
- package/src/data-model-schema.ts +1 -1
- package/src/element.ts +102 -0
- package/src/embed-protocol.ts +100 -0
- package/src/framework-support.ts +440 -0
- package/src/legal-core/index.ts +276 -0
- package/src/marketing-core/index.ts +209 -0
- package/src/marketing.ts +7 -0
- package/src/mcp-adapters/index.ts +264 -0
- package/src/mcp-adapters.ts +7 -0
- package/src/model3d-core/index.ts +248 -0
- package/src/notifications-core/index.ts +650 -0
- package/src/portal-core/index.ts +1564 -0
- package/src/portal.ts +7 -0
- package/src/push-core/index.ts +335 -0
- package/src/qwik.tsx +602 -2
- package/src/react-marketing.tsx +5 -0
- package/src/react.tsx +434 -319
- package/src/seed-core/index.ts +735 -0
- package/src/seed.ts +7 -0
- package/src/seo-core/blog.ts +216 -0
- package/src/seo-core/commerce.ts +138 -0
- package/src/seo-core/index.ts +605 -0
- package/src/seo-core/local.ts +333 -0
- package/src/sitemap-core/index.ts +296 -0
- package/src/social-core/index.ts +103 -0
- package/src/social.ts +7 -0
- package/src/solid-compat.tsx +347 -0
- package/src/solid-surfaces.tsx +1311 -0
- package/src/solid.tsx +82 -60
- package/src/store-core/index.ts +426 -0
- package/src/store.ts +12 -0
- package/src/svelte/SocialLinks.svelte +84 -0
- package/src/svelte/headless.ts +435 -0
- package/src/svelte/index.ts +9 -0
- package/src/vanilla.ts +367 -0
- package/src/verify-core/index.ts +288 -0
- package/src/verify.ts +8 -0
- package/src/vue/headless.ts +527 -0
- 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
|
+
}
|